diff --git a/internal/analytics/noop_reporter.go b/internal/analytics/noop_reporter.go index 1d414ca5c..e154d4ae5 100644 --- a/internal/analytics/noop_reporter.go +++ b/internal/analytics/noop_reporter.go @@ -25,7 +25,7 @@ func (n NoopReporter) RegisterCurrentIdentity(_ context.Context, _ kubernetes.In } // ReportCommand reports a new executed command. The command should be anonymized before using this method. -func (n NoopReporter) ReportCommand(_ config.CommPlatformIntegration, _ string, _ command.Origin) error { +func (n NoopReporter) ReportCommand(_ config.CommPlatformIntegration, _ string, _ command.Origin, _ bool) error { return nil } diff --git a/internal/analytics/reporter.go b/internal/analytics/reporter.go index 260eef6cc..d4dd14077 100644 --- a/internal/analytics/reporter.go +++ b/internal/analytics/reporter.go @@ -15,7 +15,7 @@ type Reporter interface { RegisterCurrentIdentity(ctx context.Context, k8sCli kubernetes.Interface) error // ReportCommand reports a new executed command. The command should be anonymized before using this method. - ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin) error + ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin, withFilter bool) error // ReportBotEnabled reports an enabled bot. ReportBotEnabled(platform config.CommPlatformIntegration) error diff --git a/internal/analytics/segment_reporter.go b/internal/analytics/segment_reporter.go index cf90781e8..808d0712c 100644 --- a/internal/analytics/segment_reporter.go +++ b/internal/analytics/segment_reporter.go @@ -60,11 +60,12 @@ func (r *SegmentReporter) RegisterCurrentIdentity(ctx context.Context, k8sCli ku // ReportCommand reports a new executed command. The command should be anonymized before using this method. // The RegisterCurrentIdentity needs to be called first. -func (r *SegmentReporter) ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin) error { +func (r *SegmentReporter) ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin, withFilter bool) error { return r.reportEvent("Command executed", map[string]interface{}{ "platform": platform, "command": command, "origin": origin, + "filtered": withFilter, }) } diff --git a/internal/analytics/segment_reporter_test.go b/internal/analytics/segment_reporter_test.go index ec8696e70..b3cbc6893 100644 --- a/internal/analytics/segment_reporter_test.go +++ b/internal/analytics/segment_reporter_test.go @@ -66,13 +66,13 @@ func TestSegmentReporter_ReportCommand(t *testing.T) { segmentReporter, segmentCli := fakeSegmentReporterWithIdentity(identity) // when - err := segmentReporter.ReportCommand(config.DiscordCommPlatformIntegration, "notifier stop", command.TypedOrigin) + err := segmentReporter.ReportCommand(config.DiscordCommPlatformIntegration, "notifier stop", command.TypedOrigin, false) require.NoError(t, err) - err = segmentReporter.ReportCommand(config.SlackCommPlatformIntegration, "get", command.ButtonClickOrigin) + err = segmentReporter.ReportCommand(config.SlackCommPlatformIntegration, "get", command.ButtonClickOrigin, false) require.NoError(t, err) - err = segmentReporter.ReportCommand(config.TeamsCommPlatformIntegration, "notifier start", command.SelectValueChangeOrigin) + err = segmentReporter.ReportCommand(config.TeamsCommPlatformIntegration, "notifier start", command.SelectValueChangeOrigin, false) require.NoError(t, err) // then diff --git a/internal/analytics/testdata/TestSegmentReporter_ReportCommand.json b/internal/analytics/testdata/TestSegmentReporter_ReportCommand.json index 0be462ad5..59b687dd9 100644 --- a/internal/analytics/testdata/TestSegmentReporter_ReportCommand.json +++ b/internal/analytics/testdata/TestSegmentReporter_ReportCommand.json @@ -7,6 +7,7 @@ "timestamp": "2009-11-17T20:34:58.651387237Z", "properties": { "command": "notifier stop", + "filtered": false, "origin": "typed", "platform": "discord" } @@ -19,6 +20,7 @@ "timestamp": "2009-11-17T20:34:58.651387237Z", "properties": { "command": "get", + "filtered": false, "origin": "buttonClick", "platform": "slack" } @@ -31,6 +33,7 @@ "timestamp": "2009-11-17T20:34:58.651387237Z", "properties": { "command": "notifier start", + "filtered": false, "origin": "selectValueChange", "platform": "teams" } diff --git a/pkg/bot/discord.go b/pkg/bot/discord.go index 47285c781..84cf8b7f7 100644 --- a/pkg/bot/discord.go +++ b/pkg/bot/discord.go @@ -103,7 +103,7 @@ func (b *Discord) Start(ctx context.Context) error { msg := discordMessage{ Event: m, } - if err := b.handleMessage(msg); err != nil { + if err := b.handleMessage(ctx, msg); err != nil { b.log.Errorf("Message handling error: %s", err.Error()) } }) @@ -227,7 +227,7 @@ func (b *Discord) SetNotificationsEnabled(channelID string, enabled bool) error } // HandleMessage handles the incoming messages. -func (b *Discord) handleMessage(dm discordMessage) error { +func (b *Discord) handleMessage(ctx context.Context, dm discordMessage) error { // Handle message only if starts with mention req, found := b.findAndTrimBotMention(dm.Event.Content) if !found { @@ -252,7 +252,7 @@ func (b *Discord) handleMessage(dm discordMessage) error { User: fmt.Sprintf("<@%s>", dm.Event.Author.ID), }) - response := e.Execute() + response := e.Execute(ctx) err := b.send(dm.Event, req, response) if err != nil { return fmt.Errorf("while sending message: %w", err) diff --git a/pkg/bot/mattermost.go b/pkg/bot/mattermost.go index 6becd3e8b..ea25b85dc 100644 --- a/pkg/bot/mattermost.go +++ b/pkg/bot/mattermost.go @@ -207,7 +207,7 @@ func (b *Mattermost) SetNotificationsEnabled(channelID string, enabled bool) err } // Check incoming message and take action -func (mm *mattermostMessage) handleMessage(b *Mattermost) { +func (mm *mattermostMessage) handleMessage(ctx context.Context, b *Mattermost) { post, err := postFromEvent(mm.Event) if err != nil { b.log.Error(err) @@ -239,7 +239,7 @@ func (mm *mattermostMessage) handleMessage(b *Mattermost) { }, Message: mm.Request, }) - response := e.Execute() + response := e.Execute(ctx) mm.sendMessage(b, response) } @@ -364,7 +364,7 @@ func (b *Mattermost) listen(ctx context.Context) { IsAuthChannel: false, APIClient: b.apiClient, } - mm.handleMessage(b) + mm.handleMessage(ctx, b) } } } diff --git a/pkg/bot/slack.go b/pkg/bot/slack.go index 2c01551ca..f6d1fc716 100644 --- a/pkg/bot/slack.go +++ b/pkg/bot/slack.go @@ -145,7 +145,7 @@ func (b *Slack) Start(ctx context.Context) error { ThreadTimeStamp: ev.ThreadTimestamp, User: ev.User, } - err := b.handleMessage(sm) + err := b.handleMessage(ctx, sm) if err != nil { wrappedErr := fmt.Errorf("while handling message: %w", err) b.log.Errorf(wrappedErr.Error()) @@ -215,7 +215,7 @@ func (b *Slack) SetNotificationsEnabled(channelName string, enabled bool) error return nil } -func (b *Slack) handleMessage(msg slackMessage) error { +func (b *Slack) handleMessage(ctx context.Context, msg slackMessage) error { // Handle message only if starts with mention request, found := b.findAndTrimBotMention(msg.Text) if !found { @@ -248,7 +248,7 @@ func (b *Slack) handleMessage(msg slackMessage) error { Message: request, User: fmt.Sprintf("<@%s>", msg.User), }) - response := e.Execute() + response := e.Execute(ctx) err = b.send(msg, request, response, response.OnlyVisibleForYou) if err != nil { return fmt.Errorf("while sending message: %w", err) diff --git a/pkg/bot/socketslack.go b/pkg/bot/socketslack.go index 8963b0784..9ca9a3583 100644 --- a/pkg/bot/socketslack.go +++ b/pkg/bot/socketslack.go @@ -68,7 +68,7 @@ type socketSlackMessage struct { // socketSlackAnalyticsReporter defines a reporter that collects analytics data. type socketSlackAnalyticsReporter interface { FatalErrorAnalyticsReporter - ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin) error + ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin, withFilter bool) error } // NewSocketSlack creates a new SocketSlack instance. @@ -158,7 +158,7 @@ func (b *SocketSlack) Start(ctx context.Context) error { User: ev.User, CommandOrigin: command.TypedOrigin, } - if err := b.handleMessage(msg); err != nil { + if err := b.handleMessage(ctx, msg); err != nil { b.log.Errorf("Message handling error: %s", err.Error()) } } @@ -183,7 +183,7 @@ func (b *SocketSlack) Start(ctx context.Context) error { act := callback.ActionCallback.BlockActions[0] if act == nil || strings.HasPrefix(act.ActionID, urlButtonActionIDPrefix) { - reportErr := b.reporter.ReportCommand(b.IntegrationName(), act.ActionID, command.ButtonClickOrigin) + reportErr := b.reporter.ReportCommand(b.IntegrationName(), act.ActionID, command.ButtonClickOrigin, false) if reportErr != nil { b.log.Errorf("while reporting URL command, error: %s", reportErr.Error()) } @@ -217,7 +217,7 @@ func (b *SocketSlack) Start(ctx context.Context) error { ResponseURL: callback.ResponseURL, BlockID: act.BlockID, } - if err := b.handleMessage(msg); err != nil { + if err := b.handleMessage(ctx, msg); err != nil { b.log.Errorf("Message handling error: %s", err.Error()) } case slack.InteractionTypeViewSubmission: // this event is received when modal is submitted @@ -235,7 +235,7 @@ func (b *SocketSlack) Start(ctx context.Context) error { CommandOrigin: cmdOrigin, } - if err := b.handleMessage(msg); err != nil { + if err := b.handleMessage(ctx, msg); err != nil { b.log.Errorf("Message handling error: %s", err.Error()) } } @@ -294,7 +294,7 @@ func (b *SocketSlack) SetNotificationsEnabled(channelName string, enabled bool) return nil } -func (b *SocketSlack) handleMessage(event socketSlackMessage) error { +func (b *SocketSlack) handleMessage(ctx context.Context, event socketSlackMessage) error { // Handle message only if starts with mention request, found := b.findAndTrimBotMention(event.Text) if !found { @@ -328,7 +328,7 @@ func (b *SocketSlack) handleMessage(event socketSlackMessage) error { Message: request, User: fmt.Sprintf("<@%s>", event.User), }) - response := e.Execute() + response := e.Execute(ctx) err = b.send(event, request, response) if err != nil { return fmt.Errorf("while sending message: %w", err) diff --git a/pkg/bot/teams.go b/pkg/bot/teams.go index 1f198375a..092e3c7ac 100644 --- a/pkg/bot/teams.go +++ b/pkg/bot/teams.go @@ -180,7 +180,7 @@ func (b *Teams) processActivity(w http.ResponseWriter, req *http.Request) { err = b.Adapter.ProcessActivity(ctx, activity, coreActivity.HandlerFuncs{ OnMessageFunc: func(turn *coreActivity.TurnContext) (schema.Activity, error) { - n, resp := b.processMessage(turn.Activity) + n, resp := b.processMessage(ctx, turn.Activity) if n >= teamsMaxMessageSize { if turn.Activity.Conversation.ConversationType == convTypePersonal { // send file upload request @@ -242,7 +242,7 @@ func (b *Teams) processActivity(w http.ResponseWriter, req *http.Request) { } activity.Text = consentCtx.Command - _, resp := b.processMessage(activity) + _, resp := b.processMessage(ctx, activity) actJSON, err := json.MarshalIndent(turn.Activity, "", " ") if err != nil { @@ -276,7 +276,7 @@ func (b *Teams) processActivity(w http.ResponseWriter, req *http.Request) { } } -func (b *Teams) processMessage(activity schema.Activity) (int, string) { +func (b *Teams) processMessage(ctx context.Context, activity schema.Activity) (int, string) { trimmedMsg := b.trimBotMention(activity.Text) // Multicluster is not supported for Teams @@ -300,7 +300,7 @@ func (b *Teams) processMessage(activity schema.Activity) (int, string) { }, Message: trimmedMsg, }) - return b.convertInteractiveMessage(e.Execute(), false) + return b.convertInteractiveMessage(e.Execute(ctx), false) } func (b *Teams) convertInteractiveMessage(in interactive.Message, forceMarkdown bool) (int, string) { diff --git a/pkg/execute/edit.go b/pkg/execute/edit.go index a9904bf54..86d3932a8 100644 --- a/pkg/execute/edit.go +++ b/pkg/execute/edit.go @@ -85,7 +85,7 @@ func (e *EditExecutor) Do(args []string, commGroupName string, platform config.C defer func() { cmdToReport := fmt.Sprintf("%s %s", cmdName, cmdVerb) - err := e.analyticsReporter.ReportCommand(platform, cmdToReport, conversation.CommandOrigin) + err := e.analyticsReporter.ReportCommand(platform, cmdToReport, conversation.CommandOrigin, false) if err != nil { e.log.Errorf("while reporting edit command: %s", err.Error()) } diff --git a/pkg/execute/executor.go b/pkg/execute/executor.go index 164d1367a..367bc313d 100644 --- a/pkg/execute/executor.go +++ b/pkg/execute/executor.go @@ -116,19 +116,20 @@ const ( ) // Execute executes commands and returns output -func (e *DefaultExecutor) Execute() interactive.Message { - // TODO: Pass context from bots to this method - ctx := context.Background() - - var ( - command = utils.RemoveHyperlink(e.message) - clusterName = e.cfg.Settings.ClusterName - inClusterName = utils.GetClusterNameFromKubectlCmd(command) - args = strings.Fields(strings.TrimSpace(command)) - empty = interactive.Message{} - botName = e.notifierHandler.BotName() - ) +func (e *DefaultExecutor) Execute(ctx context.Context) interactive.Message { + empty := interactive.Message{} + rawCmd := utils.RemoveAnyHyperlinks(e.message) + rawCmd = strings.NewReplacer(`“`, `"`, `”`, `"`, `‘`, `"`, `’`, `"`).Replace(rawCmd) + clusterName := e.cfg.Settings.ClusterName + inClusterName := utils.GetClusterNameFromKubectlCmd(rawCmd) + botName := e.notifierHandler.BotName() + + execFilter, err := extractExecutorFilter(rawCmd) + if err != nil { + return e.respond(err.Error(), rawCmd, "", botName) + } + args := strings.Fields(rawCmd) if len(args) == 0 { if e.conversation.IsAuthenticated { return interactive.Message{ @@ -140,29 +141,6 @@ func (e *DefaultExecutor) Execute() interactive.Message { return empty // this prevents all bots on all clusters to answer something } - response := func(msg string, overrideCommand ...string) interactive.Message { - msgBody := interactive.Body{ - CodeBlock: msg, - } - if msg == "" { - msgBody = interactive.Body{ - Plaintext: emptyResponseMsg, - } - } - - message := interactive.Message{ - Base: interactive.Base{ - Description: e.header(command, overrideCommand...), - Body: msgBody, - }, - } - // Show Filter Input if command response is more than `lineLimitToShowFilter` - if len(strings.SplitN(msg, "\n", lineLimitToShowFilter)) == lineLimitToShowFilter { - message.PlaintextInputs = append(message.PlaintextInputs, e.filterInput(command, botName)) - } - return message - } - if inClusterName != "" && inClusterName != clusterName { e.log.WithFields(logrus.Fields{ "config-cluster-name": clusterName, @@ -172,18 +150,18 @@ func (e *DefaultExecutor) Execute() interactive.Message { } if e.kubectlExecutor.CanHandle(e.conversation.ExecutorBindings, args) { - e.reportCommand(e.kubectlExecutor.GetCommandPrefix(args)) - out, err := e.kubectlExecutor.Execute(e.conversation.ExecutorBindings, e.message, e.conversation.IsAuthenticated) + e.reportCommand(e.kubectlExecutor.GetCommandPrefix(args), execFilter.IsActive()) + out, err := e.kubectlExecutor.Execute(e.conversation.ExecutorBindings, execFilter.FilteredCommand(), e.conversation.IsAuthenticated) switch { case err == nil: case IsExecutionCommandError(err): - return response(err.Error()) + return e.respond(err.Error(), rawCmd, execFilter.FilteredCommand(), botName) default: // TODO: Return error when the DefaultExecutor is refactored as a part of https://github.com/kubeshop/botkube/issues/589 e.log.Errorf("while executing kubectl: %s", err.Error()) return empty } - return response(out) + return e.respond(execFilter.Apply(out), rawCmd, execFilter.FilteredCommand(), botName) } // commands below are executed only if the channel is authorized @@ -192,8 +170,8 @@ func (e *DefaultExecutor) Execute() interactive.Message { } if e.kubectlCmdBuilder.CanHandle(args) { - e.reportCommand(e.kubectlCmdBuilder.GetCommandPrefix(args)) - out, err := e.kubectlCmdBuilder.Do(ctx, args, e.platform, e.conversation.ExecutorBindings, e.conversation.State, botName, e.header(command)) + e.reportCommand(e.kubectlCmdBuilder.GetCommandPrefix(args), false) + out, err := e.kubectlCmdBuilder.Do(ctx, args, e.platform, e.conversation.ExecutorBindings, e.conversation.State, botName, e.header(rawCmd)) if err != nil { // TODO: Return error when the DefaultExecutor is refactored as a part of https://github.com/kubeshop/botkube/issues/589 e.log.Errorf("while executing kubectl: %s", err.Error()) @@ -204,33 +182,33 @@ func (e *DefaultExecutor) Execute() interactive.Message { cmds := executorsRunner{ "help": func() (interactive.Message, error) { - e.reportCommand(args[0]) + e.reportCommand(args[0], false) return interactive.NewHelpMessage(e.platform, clusterName, botName).Build(), nil }, "ping": func() (interactive.Message, error) { res := e.runVersionCommand("ping") - return response(fmt.Sprintf("pong\n\n%s", res)), nil + return e.respond(fmt.Sprintf("pong\n\n%s", res), rawCmd, execFilter.FilteredCommand(), botName), nil }, "version": func() (interactive.Message, error) { - return response(e.runVersionCommand("version")), nil + return e.respond(e.runVersionCommand("version"), rawCmd, execFilter.FilteredCommand(), botName), nil }, "filters": func() (interactive.Message, error) { res, err := e.runFilterCommand(ctx, args, clusterName) - return response(res), err + return e.respond(execFilter.Apply(res), rawCmd, execFilter.FilteredCommand(), botName), err }, "commands": func() (interactive.Message, error) { - res, err := e.runInfoCommand(args) - return response(res, humanReadableCommandListName), err + res, err := e.runInfoCommand(args, execFilter.IsActive()) + return e.respond(execFilter.Apply(res), rawCmd, execFilter.FilteredCommand(), botName, humanReadableCommandListName), err }, "notifier": func() (interactive.Message, error) { res, err := e.notifierExecutor.Do(ctx, args, e.commGroupName, e.platform, e.conversation, clusterName, e.notifierHandler) - return response(res), err + return e.respond(res, rawCmd, execFilter.FilteredCommand(), botName), err }, "edit": func() (interactive.Message, error) { return e.editExecutor.Do(args, e.commGroupName, e.platform, e.conversation, e.user, botName) }, "feedback": func() (interactive.Message, error) { - e.reportCommand(args[0]) + e.reportCommand(args[0], false) return interactive.Feedback(), nil }, } @@ -239,20 +217,43 @@ func (e *DefaultExecutor) Execute() interactive.Message { switch { case err == nil: case errors.Is(err, errInvalidCommand): - return response(incompleteCmdMsg) + return e.respond(incompleteCmdMsg, rawCmd, execFilter.FilteredCommand(), botName) case errors.Is(err, errUnsupportedCommand): - return response(unsupportedCmdMsg) + return e.respond(unsupportedCmdMsg, rawCmd, execFilter.FilteredCommand(), botName) case IsExecutionCommandError(err): - return response(err.Error()) + return e.respond(err.Error(), rawCmd, execFilter.FilteredCommand(), botName) default: - e.log.Errorf("while executing command %q: %s", command, err.Error()) + e.log.Errorf("while executing command %q: %s", execFilter.FilteredCommand(), err.Error()) internalErrorMsg := fmt.Sprintf(internalErrorMsgFmt, clusterName) - return response(internalErrorMsg) + return e.respond(internalErrorMsg, rawCmd, execFilter.FilteredCommand(), botName) } return msg } +func (e *DefaultExecutor) respond(msg string, rawCmd string, filteredCmd string, botName string, overrideCommand ...string) interactive.Message { + msgBody := interactive.Body{ + CodeBlock: msg, + } + if msg == "" { + msgBody = interactive.Body{ + Plaintext: emptyResponseMsg, + } + } + + message := interactive.Message{ + Base: interactive.Base{ + Description: e.header(rawCmd, overrideCommand...), + Body: msgBody, + }, + } + // Show Filter Input if command response is more than `lineLimitToShowFilter` + if len(strings.SplitN(msg, "\n", lineLimitToShowFilter)) == lineLimitToShowFilter { + message.PlaintextInputs = append(message.PlaintextInputs, e.filterInput(filteredCmd, botName)) + } + return message +} + func (e *DefaultExecutor) header(command string, overrideName ...string) string { cmd := fmt.Sprintf("`%s`", strings.TrimSpace(command)) if len(overrideName) > 0 { @@ -263,8 +264,8 @@ func (e *DefaultExecutor) header(command string, overrideName ...string) string return e.appendByUserOnlyIfNeeded(out) } -func (e *DefaultExecutor) reportCommand(verb string) { - err := e.analyticsReporter.ReportCommand(e.platform, verb, e.conversation.CommandOrigin) +func (e *DefaultExecutor) reportCommand(verb string, withFilter bool) { + err := e.analyticsReporter.ReportCommand(e.platform, verb, e.conversation.CommandOrigin, withFilter) if err != nil { e.log.Errorf("while reporting %s command: %s", verb, err.Error()) } @@ -280,7 +281,7 @@ func (e *DefaultExecutor) runFilterCommand(ctx context.Context, args []string, c var cmdVerb = args[1] defer func() { cmdToReport := fmt.Sprintf("%s %s", args[0], cmdVerb) - e.reportCommand(cmdToReport) + e.reportCommand(cmdToReport, false) }() switch FiltersAction(args[1]) { @@ -332,14 +333,14 @@ func (e *DefaultExecutor) runFilterCommand(ctx context.Context, args []string, c } // runInfoCommand to list allowed commands -func (e *DefaultExecutor) runInfoCommand(args []string) (string, error) { +func (e *DefaultExecutor) runInfoCommand(args []string, withFilter bool) (string, error) { if len(args) < 2 { return "", errInvalidCommand } var cmdVerb = args[1] defer func() { cmdToReport := fmt.Sprintf("%s %s", args[0], cmdVerb) - e.reportCommand(cmdToReport) + e.reportCommand(cmdToReport, withFilter) }() switch infoAction(cmdVerb) { @@ -417,7 +418,11 @@ func (e *DefaultExecutor) findBotkubeVersion() (versions string) { } func (e *DefaultExecutor) runVersionCommand(cmd string) string { - e.reportCommand(cmd) + err := e.analyticsReporter.ReportCommand(e.platform, cmd, e.conversation.CommandOrigin, false) + if err != nil { + e.log.Errorf("while reporting version command: %s", err.Error()) + } + return e.findBotkubeVersion() } diff --git a/pkg/execute/executor_filter.go b/pkg/execute/executor_filter.go new file mode 100644 index 000000000..6dc3fb236 --- /dev/null +++ b/pkg/execute/executor_filter.go @@ -0,0 +1,161 @@ +package execute + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/mattn/go-shellwords" + "github.com/spf13/pflag" +) + +const ( + incorrectFilterFlag = "incorrect use of --filter flag: %s" + filterFlagParseErrorMsg = `incorrect use of --filter flag: could not parse flag in %s.\nerror: %s\nuse --filter="value" or --filter value` + missingCmdFilterValue = `incorrect use of --filter flag: an argument is missing. use --filter="value" or --filter value` + multipleFilters = "incorrect use of --filter flag: found more than one filter flag." +) + +// executorFilter interface to implement to filter executor text based results +type executorFilter interface { + Apply(string) string + FilteredCommand() string + IsActive() bool +} + +// executorEchoFilter echos given text when asked to filter executor text results. +// Mainly used when executor commands are missing a "--filter=xxx" flag. +type executorEchoFilter struct { + command string +} + +// FilteredCommand returns the command whose results the filter will be applied on. +func (f *executorEchoFilter) FilteredCommand() string { + return f.command +} + +// IsActive whether this filter will actually mutate the output or not. +func (f *executorEchoFilter) IsActive() bool { + return false +} + +// Apply implements executorFilter to apply filtering. +func (f *executorEchoFilter) Apply(text string) string { + return text +} + +// newExecutorEchoFilter creates a new executorEchoFilter. +func newExecutorEchoFilter(command string) *executorEchoFilter { + return &executorEchoFilter{ + command: command, + } +} + +// executorTextFilter filters executor text results by a given text value. +type executorTextFilter struct { + value []byte + command string +} + +// FilteredCommand returns the command whose results the filter will be applied on. +func (f *executorTextFilter) FilteredCommand() string { + return f.command +} + +// IsActive whether this filter will actually mutate the output or not. +func (f *executorTextFilter) IsActive() bool { + return true +} + +// newExecutorTextFilter creates a new executorTextFilter. +func newExecutorTextFilter(val, command string) *executorTextFilter { + return &executorTextFilter{ + value: []byte(val), + command: command, + } +} + +// Apply implements executorFilter to apply filtering. +func (f *executorTextFilter) Apply(text string) string { + var out strings.Builder + + scanner := bufio.NewScanner(strings.NewReader(text)) + for scanner.Scan() { + scanned := scanner.Bytes() + if bytes.Contains(scanned, f.value) { + out.Write(bytes.TrimSpace(scanned)) + out.WriteString("\n") + } + } + + return strings.TrimSuffix(out.String(), "\n") +} + +// extractExecutorFilter extracts an executorFilter based on +// the presence or absence of the "--filter=xxx" flag. +// It also returns passed in executor command minus the +// flag to be executed by downstream executors and if a filter flag was detected. +// ignore unknown flags errors, e.g. `--cluster-name` etc. +func extractExecutorFilter(cmd string) (executorFilter, error) { + var filters []string + + filters, err := parseAndValidateAnyFilters(cmd) + if err != nil { + return nil, err + } + + if len(filters) == 0 { + return newExecutorEchoFilter(cmd), nil + } + + if len(filters[0]) == 0 { + return nil, errors.New(missingCmdFilterValue) + } + + filterVal := filters[0] + escapedFilterVal := regexp.QuoteMeta(filterVal) + filterFlagRegex, err := regexp.Compile(fmt.Sprintf(`--filter[=|(' ')]*('%s'|"%s"|%s)("|')*`, + escapedFilterVal, + escapedFilterVal, + escapedFilterVal)) + if err != nil { + return nil, errors.New("could not extract provided filter") + } + + matches := filterFlagRegex.FindStringSubmatch(cmd) + if len(matches) == 0 { + return nil, fmt.Errorf(filterFlagParseErrorMsg, cmd, "it contains unsupported characters.") + } + return newExecutorTextFilter(filterVal, strings.ReplaceAll(cmd, fmt.Sprintf(" %s", matches[0]), "")), nil +} + +// parseAndValidateAnyFilters parses any filter flags returning their values or an error. +func parseAndValidateAnyFilters(cmd string) ([]string, error) { + var out []string + + args, err := shellwords.Parse(cmd) + if err != nil { + return nil, fmt.Errorf(filterFlagParseErrorMsg, cmd, err.Error()) + } + + f := pflag.NewFlagSet("extract-filters", pflag.ContinueOnError) + f.ParseErrorsWhitelist.UnknownFlags = true + + f.StringArrayVar(&out, "filter", []string{}, "Output filter") + if err := f.Parse(args); err != nil { + return nil, fmt.Errorf(incorrectFilterFlag, err) + } + + if len(out) > 1 { + return nil, errors.New(multipleFilters) + } + + if len(out) == 1 && (strings.HasPrefix(out[0], "-")) { + return nil, errors.New(missingCmdFilterValue) + } + + return out, nil +} diff --git a/pkg/execute/executor_filter_test.go b/pkg/execute/executor_filter_test.go new file mode 100644 index 000000000..1e25f8bbf --- /dev/null +++ b/pkg/execute/executor_filter_test.go @@ -0,0 +1,189 @@ +package execute + +import ( + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/stretchr/testify/assert" +) + +func TestExecutorEchoFilter_Apply(t *testing.T) { + var filter executorFilter = newExecutorEchoFilter("") + + text := "Please return this same text." + assert.Equal(t, text, filter.Apply(text)) +} + +func TestExecutorTextFilter_Apply(t *testing.T) { + testCases := []struct { + name string + text string + expected string + }{ + { + name: "filter multi line text", + text: heredoc.Doc(` +NAME READY STATUS RESTARTS AGE +pod/coredns-558bd4d5db-c5gwx 1/1 Running 0 30m +pod/coredns-558bd4d5db-j5wqt 1/1 Running 0 30m +pod/etcd-kind-control-plane 1/1 Running 0 30m +pod/kindnet-hl6zc 1/1 Running 0 29m +pod/kindnet-tc254 1/1 Running 0 30m +pod/kindnet-x79x6 1/1 Running 0 29m + +NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE +daemonset.apps/kindnet 3 3 3 3 3 30m +daemonset.apps/kube-proxy 3 3 3 3 3 kubernetes.io/os=linux 30m`), + expected: heredoc.Doc(` +pod/etcd-kind-control-plane 1/1 Running 0 30m +pod/kindnet-hl6zc 1/1 Running 0 29m +pod/kindnet-tc254 1/1 Running 0 30m +pod/kindnet-x79x6 1/1 Running 0 29m +daemonset.apps/kindnet 3 3 3 3 3 30m`), + }, + { + name: "filter single line text", + text: `pod/etcd-kind-control-plane 1/1 Running 0 30m`, + expected: `pod/etcd-kind-control-plane 1/1 Running 0 30m`, + }, + { + name: "no match filter", + text: `pod/etcd-control-plane 1/1 Running 0 30m`, + expected: "", + }, + } + + var txFilter executorFilter = newExecutorTextFilter("kind", "") + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.expected, txFilter.Apply(tc.text)) + }) + } +} + +func TestExtractExecutorFilter_NoErrors(t *testing.T) { + testCases := []struct { + name string + cmd string + extractedCmd string + text string + filterApplied string + filterActive bool + }{ + { + name: "extract unquoted text filter at end of command", + cmd: `kubectl get po -n kube-system --filter=kind`, + extractedCmd: "kubectl get po -n kube-system", + text: `etcd-kind-control-plane 1/1 Running 0 86m`, + filterApplied: `etcd-kind-control-plane 1/1 Running 0 86m`, + filterActive: true, + }, + { + name: "extract unquoted text filter in the middle of the command", + cmd: `kubectl get po --filter=kind -n kube-system`, + extractedCmd: "kubectl get po -n kube-system", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: "", + filterActive: true, + }, + { + name: "extract single quoted text filter in the middle of the command", + cmd: `kubectl get po --filter="kind system" -n kube-system`, + extractedCmd: "kubectl get po -n kube-system", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: "", + filterActive: true, + }, + { + name: "extract double quoted text filter in the middle of the command", + cmd: `kubectl get po --filter="kind" -n kube-system`, + extractedCmd: "kubectl get po -n kube-system", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: "", + filterActive: true, + }, + { + name: "extract double quoted text filter with extra spaces in the command", + cmd: `kubectl get po --filter "kind" -n kube-system`, + extractedCmd: "kubectl get po -n kube-system", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: "", + filterActive: true, + }, + { + name: "extract double quoted text filter with special characters", + cmd: `kubectl get po -A --filter "botkube. . [] *? ^ ===== /test/"`, + extractedCmd: "kubectl get po -A", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: "", + filterActive: true, + }, + { + name: "extract double quoted text filter with a file path", + cmd: `kubectl get po -A --filter "=./Users/botkube/somefile.txt [info]"`, + extractedCmd: "kubectl get po -A", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: "", + filterActive: true, + }, + { + name: "extract echo filter from command", + cmd: "kubectl get po -n kube-system", + extractedCmd: "kubectl get po -n kube-system", + text: `etcd-control-plane 1/1 Running 0 86m`, + filterApplied: `etcd-control-plane 1/1 Running 0 86m`, + filterActive: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + filter, err := extractExecutorFilter(tc.cmd) + assert.Nil(t, err) + assert.Equal(t, tc.extractedCmd, filter.FilteredCommand()) + assert.Equal(t, tc.filterApplied, filter.Apply(tc.text)) + assert.Equal(t, tc.filterActive, filter.IsActive()) + }) + } +} + +func TestExtractExecutorFilter_WithErrors(t *testing.T) { + testCases := []struct { + name string + cmd string + errMsg string + }{ + { + name: "raise error when filter value is missing at end of command", + cmd: "kubectl get po -n kube-system --filter", + errMsg: `flag needs an argument`, + }, + { + name: "raise error when filter value is missing in the middle of command", + cmd: "kubectl get po --filter -n kube-system", + errMsg: `an argument is missing`, + }, + { + name: "raise error when multiple filter flags with values are used in command", + cmd: "kubectl get po --filter hello --filter='world' -n kube-system", + errMsg: `found more than one filter flag`, + }, + { + name: "raise error when multiple filter flags with no values are used in command", + cmd: "kubectl get po --filter --filter -n kube-system", + errMsg: `an argument is missing`, + }, + { + name: "raise error when filter flag with equal operator and extra spaces in the command", + cmd: `kubectl get po --filter= "kind" -n kube-system`, + errMsg: `an argument is missing`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := extractExecutorFilter(tc.cmd) + assert.ErrorContains(t, err, tc.errMsg) + }) + } +} diff --git a/pkg/execute/factory.go b/pkg/execute/factory.go index 569c5a905..e1940cde4 100644 --- a/pkg/execute/factory.go +++ b/pkg/execute/factory.go @@ -44,7 +44,7 @@ type DefaultExecutorFactoryParams struct { // Executor is an interface for processes to execute commands type Executor interface { - Execute() interactive.Message + Execute(context.Context) interactive.Message } // ConfigPersistenceManager manages persistence of the configuration. @@ -57,7 +57,7 @@ type ConfigPersistenceManager interface { // AnalyticsReporter defines a reporter that collects analytics data. type AnalyticsReporter interface { // ReportCommand reports a new executed command. The command should be anonymized before using this method. - ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin) error + ReportCommand(platform config.CommPlatformIntegration, command string, origin command.Origin, withFilter bool) error } // CommandGuard is an interface that allows to check if a given command is allowed to be executed. diff --git a/pkg/execute/helper_test.go b/pkg/execute/helper_test.go index b1bed489e..efbf42e77 100644 --- a/pkg/execute/helper_test.go +++ b/pkg/execute/helper_test.go @@ -10,7 +10,7 @@ import ( type fakeAnalyticsReporter struct{} -func (f *fakeAnalyticsReporter) ReportCommand(_ config.CommPlatformIntegration, _ string, _ command.Origin) error { +func (f *fakeAnalyticsReporter) ReportCommand(_ config.CommPlatformIntegration, _ string, _ command.Origin, _ bool) error { return nil } diff --git a/pkg/execute/kubectl_cmd_builder.go b/pkg/execute/kubectl_cmd_builder.go index f3c3ac32a..1419747f2 100644 --- a/pkg/execute/kubectl_cmd_builder.go +++ b/pkg/execute/kubectl_cmd_builder.go @@ -24,7 +24,7 @@ const ( resourceTypesDropdownCommand = "kc-cmd-builder --resource-type" resourceNamesDropdownCommand = "kc-cmd-builder --resource-name" resourceNamespaceDropdownCommand = "kc-cmd-builder --namespace" - filterPlaintextInputCommand = "kc-cmd-builder --filter" + filterPlaintextInputCommand = "kc-cmd-builder --filter-query" kubectlCommandName = "kubectl" dropdownItemsLimit = 100 noKubectlCommandsInChannel = "No `kubectl` commands are enabled in this channel. To learn how to enable them, visit https://botkube.io/docs/configuration/executor." diff --git a/pkg/execute/kubectl_cmd_builder_test.go b/pkg/execute/kubectl_cmd_builder_test.go index a743ade2a..f0ae158d8 100644 --- a/pkg/execute/kubectl_cmd_builder_test.go +++ b/pkg/execute/kubectl_cmd_builder_test.go @@ -446,7 +446,7 @@ func fixStateBuilderMessage(kcCommandPreview, kcCommand string, dropdowns ...int }, PlaintextInputs: interactive.LabelInputs{ interactive.LabelInput{ - ID: "@BKTesting kc-cmd-builder --filter ", + ID: "@BKTesting kc-cmd-builder --filter-query ", DispatchedAction: interactive.DispatchInputActionOnCharacter, Text: "Filter output", Placeholder: "(Optional) Type to filter command output by.", diff --git a/pkg/execute/notifier.go b/pkg/execute/notifier.go index a2bc71584..8912de4b2 100644 --- a/pkg/execute/notifier.go +++ b/pkg/execute/notifier.go @@ -69,7 +69,7 @@ func (e *NotifierExecutor) Do(ctx context.Context, args []string, commGroupName cmdVerb = anonymizedInvalidVerb // prevent passing any personal information } cmdToReport := fmt.Sprintf("%s %s", args[0], cmdVerb) - err := e.analyticsReporter.ReportCommand(platform, cmdToReport, conversation.CommandOrigin) + err := e.analyticsReporter.ReportCommand(platform, cmdToReport, conversation.CommandOrigin, false) if err != nil { // TODO: Return error when the DefaultExecutor is refactored as a part of https://github.com/kubeshop/botkube/issues/589 e.log.Errorf("while reporting notifier command: %s", err.Error()) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index d949b9d7c..e683f2920 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -155,8 +155,8 @@ func Contains(a []string, x string) bool { return false } -// RemoveHyperlink removes the hyperlink text from url -func RemoveHyperlink(hyperlink string) string { +// RemoveAnyHyperlinks removes the hyperlink text from url +func RemoveAnyHyperlinks(hyperlink string) string { command := hyperlink compiledRegex := regexp.MustCompile(hyperlinkRegex) matched := compiledRegex.FindAllStringSubmatch(string(hyperlink), -1) diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 407ffb2cb..f871f01ff 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -77,7 +77,7 @@ func TestRemoveHyperlink(t *testing.T) { } for _, ts := range tests { - got := RemoveHyperlink(ts.input) + got := RemoveAnyHyperlinks(ts.input) if got != ts.expected { t.Errorf("expected: %v, got: %v", ts.expected, got) } diff --git a/test/e2e/bots_test.go b/test/e2e/bots_test.go index 01ff535b0..6c91db3af 100644 --- a/test/e2e/bots_test.go +++ b/test/e2e/bots_test.go @@ -282,6 +282,9 @@ func runBotTest(t *testing.T, - wait resources: [] restrictAccess: false`)) + expectedFilteredBody := codeBlock(heredoc.Doc(` + - api-resources + - api-versions`)) expectedMessage := fmt.Sprintf("Available kubectl commands on `%s`\n%s", appCfg.ClusterName, expectedBody) t.Run("With default cluster", func(t *testing.T) { @@ -299,6 +302,15 @@ func runBotTest(t *testing.T, assert.NoError(t, err) }) + t.Run("With custom cluster name and filter", func(t *testing.T) { + command := fmt.Sprintf("commands list --cluster-name %s --filter=api", appCfg.ClusterName) + expectedMessage := fmt.Sprintf("Available kubectl commands on `%s`\n%s", appCfg.ClusterName, expectedFilteredBody) + + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + err = botDriver.WaitForLastMessageEqual(botDriver.BotUserID(), botDriver.Channel().ID(), expectedMessage) + assert.NoError(t, err) + }) + t.Run("With unknown cluster name", func(t *testing.T) { command := "commands list --cluster-name non-existing" @@ -324,6 +336,18 @@ func runBotTest(t *testing.T, assert.NoError(t, err) }) + t.Run("Get Deployment with matching filter", func(t *testing.T) { + command := fmt.Sprintf(`get deploy -n %s %s --filter='botkube'`, appCfg.Deployment.Namespace, appCfg.Deployment.Name) + assertionFn := func(msg string) (bool, int, string) { + return strings.Contains(msg, heredoc.Doc(fmt.Sprintf("`%s` on `%s`", command, appCfg.ClusterName))) && + strings.Contains(msg, "botkube"), 0, "" + } + + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) + assert.NoError(t, err) + }) + t.Run("Get Configmap", func(t *testing.T) { command := fmt.Sprintf("get configmap -n %s", appCfg.Deployment.Namespace) assertionFn := func(msg string) (bool, int, string) { @@ -337,6 +361,19 @@ func runBotTest(t *testing.T, assert.NoError(t, err) }) + t.Run("Get Configmap with mismatching filter", func(t *testing.T) { + command := fmt.Sprintf(`get configmap -n %s --filter="unknown-thing"`, appCfg.Deployment.Namespace) + assertionFn := func(msg string) (bool, int, string) { + return strings.Contains(msg, heredoc.Doc(fmt.Sprintf("`%s` on `%s`", command, appCfg.ClusterName))) && + !strings.Contains(msg, "kube-root-ca.crt") && + !strings.Contains(msg, "botkube-global-config"), 0, "" + } + + botDriver.PostMessageToBot(t, botDriver.Channel().Identifier(), command) + err = botDriver.WaitForMessagePosted(botDriver.BotUserID(), botDriver.Channel().ID(), 1, assertionFn) + assert.NoError(t, err) + }) + t.Run("Receive large output as plaintext file with executor command as message", func(t *testing.T) { command := fmt.Sprintf("get configmap %s -o yaml -n %s", globalConfigMapName, appCfg.Deployment.Namespace) fileUploadAssertionFn := func(title, mimetype string) bool {