diff --git a/config.yaml b/config.yaml index 080388777..09a913c1c 100644 --- a/config.yaml +++ b/config.yaml @@ -125,5 +125,3 @@ settings: clustername: not-configured # Set false to disable kubectl commands execution allowkubectl: false - # Set true only respond to channel in config - checkchannel: false \ No newline at end of file diff --git a/design/multi-cluster.md b/design/multi-cluster.md new file mode 100644 index 000000000..8337f640f --- /dev/null +++ b/design/multi-cluster.md @@ -0,0 +1,51 @@ +# Multi-cluster Support + +#### Assumptions +`@botkube` commands refer to all the commands in the slack bot which currently supports: +- kubectl +- notifier +- ping + + +### Summary +Add Multi-cluster support for Botkube, where a single bot can monitor multiple clusters and respond to `@botkube` commands with cluster specific results. + +### Motivation +Currently in multi-cluster scenario, a Slack bot authenticates all the clusters with a same authentication token. Thus running `@botkube` command returns response from all the configured clusters, irrespective of the slack channel or group. For `@botkube` command execution, we need a particular cluster specific output. + +### Design + +This design approach adds a flag `--cluster-name` to all `@botkube` commands. Use of that flag is optional in a cluster specific channel. + +Botkube `Notifier` commands are restricted to a dedicated channel for a cluster only and `--cluster-name` flag is ignored. + +Botkube `ping` command with the `--cluster-name` flag returns `pong` response from the cluster specified in the flag, else you get response from all the clusters. `Ping` command without --cluster-name flag can be used to list all the configured clusters in the slack bot and identify you cluster's name among them. + +For `kubectl` commands in a dedicated channel to a cluster, if `--cluster-name` flag is used, it responds with the output for the cluster specified in flag, else it checks if the channel in the request matches the `config.Communications.Slack.Channel` and responds if true else ignores. + +For `kubectl` commands in a group, Direct message or channel not dedicated to any cluster, the `--cluster-name` flag is mandatory. The executor checks if the `--cluster-name` flag is present in the request. If yes, it gets the cluster's name from the flag and compares with `c.Settings.ClusterName` from the config file, if it matches then it responds with the required output to the slack bot and if it doesn't match, it ignores the request. And if the `--cluster-name` flag is absent for kubectl commands, it responds to the slack bot saying 'Please specify the cluster-name'. + +For example - +```sh +@Botkube get pods --cluster-name={CLUSTER_NAME} +``` +where, +`CLUSTER_NAME` is the name of the cluster you want to query. + +To get the list of all clusters configured in the slack, you can run the following command in slack. + +```sh +@Botkube ping +``` + +##### Workflow + +![Multi_Cluster_Design](workflow.png) + + +### Drawbacks +The `--cluster-name` flag is mandated for kubectl and notifier commands resulting additional overhead. + +### Alternatives +We can add channel specific authentication token or completely dedicate a channel to a particular cluster which requires changes in the slack code. + diff --git a/design/workflow.png b/design/workflow.png new file mode 100644 index 000000000..9db49de49 Binary files /dev/null and b/design/workflow.png differ diff --git a/helm/botkube/values.yaml b/helm/botkube/values.yaml index 97c801790..b92b74e2a 100644 --- a/helm/botkube/values.yaml +++ b/helm/botkube/values.yaml @@ -140,8 +140,6 @@ config: clustername: not-configured # Set false to disable kubectl commands execution allowkubectl: false - # Set true only respond to channel in config - checkchannel: false resources: {} # We usually recommend not to specify default resources and to leave this as a conscious diff --git a/pkg/config/config.go b/pkg/config/config.go index 128d00b2c..5abd8d988 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -49,7 +49,6 @@ type Slack struct { type Settings struct { ClusterName string AllowKubectl bool - CheckChannel bool } // New returns new Config diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index 2c15e2505..f41f6093d 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "os" "os/signal" "strconv" @@ -20,13 +21,13 @@ import ( "k8s.io/client-go/tools/cache" ) -var startTime time.Time - const ( - controllerStartMsg = "...and now my watch begins! :crossed_swords:" - controllerStopMsg = "my watch has ended!" + controllerStartMsg = "...and now my watch begins for cluster '%s'! :crossed_swords:" + controllerStopMsg = "my watch has ended for cluster '%s'!" ) +var startTime time.Time + func findNamespace(ns string) string { if ns == "all" { return apiV1.NamespaceAll @@ -39,7 +40,7 @@ func findNamespace(ns string) string { // RegisterInformers creates new informer controllers to watch k8s resources func RegisterInformers(c *config.Config) { - sendMessage(controllerStartMsg) + sendMessage(fmt.Sprintf(controllerStartMsg, c.Settings.ClusterName)) startTime = time.Now().Local() // Get resync period @@ -127,7 +128,7 @@ func RegisterInformers(c *config.Config) { signal.Notify(sigterm, syscall.SIGTERM) signal.Notify(sigterm, syscall.SIGINT) <-sigterm - sendMessage(controllerStopMsg) + sendMessage(fmt.Sprintf(controllerStopMsg, c.Settings.ClusterName)) } func registerEventHandlers(resourceType string, events []string) (handlerFns cache.ResourceEventHandlerFuncs) { diff --git a/pkg/execute/executor.go b/pkg/execute/executor.go index 06a2a5f64..91e3f20d1 100644 --- a/pkg/execute/executor.go +++ b/pkg/execute/executor.go @@ -1,6 +1,7 @@ package execute import ( + "fmt" "io/ioutil" "os" "os/exec" @@ -25,19 +26,23 @@ var validKubectlCommands = map[string]bool{ "auth": true, } -var validNotifierCommands = map[string]bool{ +var validNotifierCommand = map[string]bool{ "notifier": true, - "help": true, - "ping": true, +} +var validPingCommand = map[string]bool{ + "ping": true, +} +var validHelpCommand = map[string]bool{ + "help": true, } var kubectlBinary = "/usr/local/bin/kubectl" const ( - notifierStartMsg = "Brace yourselves, notifications are coming." - notifierStopMsg = "Sure! I won't send you notifications anymore." + notifierStartMsg = "Brace yourselves, notifications are coming from cluster '%s'." + notifierStopMsg = "Sure! I won't send you notifications from cluster '%s' anymore." unsupportedCmdMsg = "Command not supported. Please run '@BotKube help' to see supported commands." - kubectlDisabledMsg = "Sorry, the admin hasn't given me the permission to execute kubectl command." + kubectlDisabledMsg = "Sorry, the admin hasn't given me the permission to execute kubectl command on cluster '%s'." ) // Executor is an interface for processes to execute commands @@ -47,15 +52,52 @@ type Executor interface { // DefaultExecutor is a default implementations of Executor type DefaultExecutor struct { - Message string - AllowKubectl bool + Message string + AllowKubectl bool + ClusterName string + ChannelName string + IsAuthChannel bool +} + +// NotifierAction creates custom type for notifier actions +type NotifierAction string + +// Defines constants for notifier actions +const ( + Start NotifierAction = "start" + Stop NotifierAction = "stop" + Status NotifierAction = "status" + ShowConfig NotifierAction = "showconfig" +) + +func (action NotifierAction) String() string { + return string(action) +} + +// CommandFlags creates custom type for flags in botkube +type CommandFlags string + +// Defines botkube flags +const ( + ClusterFlag CommandFlags = "--cluster-name" + FollowFlag CommandFlags = "--follow" + AbbrFollowFlag CommandFlags = "-f" + WatchFlag CommandFlags = "--watch" + AbbrWatchFlag CommandFlags = "-w" +) + +func (flag CommandFlags) String() string { + return string(flag) } // NewDefaultExecutor returns new Executor object -func NewDefaultExecutor(msg string, allowkubectl bool) Executor { +func NewDefaultExecutor(msg string, allowkubectl bool, clusterName, channelName string, isAuthChannel bool) Executor { return &DefaultExecutor{ - Message: msg, - AllowKubectl: allowkubectl, + Message: msg, + AllowKubectl: allowkubectl, + ClusterName: clusterName, + ChannelName: channelName, + IsAuthChannel: isAuthChannel, } } @@ -64,110 +106,170 @@ func (e *DefaultExecutor) Execute() string { args := strings.Split(e.Message, " ") if validKubectlCommands[args[0]] { if !e.AllowKubectl { - return kubectlDisabledMsg + return fmt.Sprintf(kubectlDisabledMsg, e.ClusterName) } - return runKubectlCommand(args) + return runKubectlCommand(args, e.ClusterName, e.IsAuthChannel) } - if validNotifierCommands[args[0]] { - return runNotifierCommand(args) + if validNotifierCommand[args[0]] { + return runNotifierCommand(args, e.ClusterName, e.IsAuthChannel) + } + if validPingCommand[args[0]] { + return runPingCommand(args, e.ClusterName) + } + if validHelpCommand[args[0]] { + return printHelp(e.ChannelName) } return unsupportedCmdMsg } -func printHelp() string { - allowedKubectl := "" - for k := range validKubectlCommands { - allowedKubectl = allowedKubectl + k + ", " +func printHelp(channelName string) string { + kubecltCmdKeys := make([]string, 0, len(validKubectlCommands)) + for cmd := range validKubectlCommands { + kubecltCmdKeys = append(kubecltCmdKeys, cmd) } - helpMsg := "BotKube executes kubectl commands on k8s cluster and returns output.\n" + - "Usages:\n" + - " @BotKube \n" + - "e.g:\n" + - " @BotKube get pods\n" + - " @BotKube logs podname -n namespace\n" + - "Allowed kubectl commands:\n" + - " " + allowedKubectl + "\n\n" + - "Commands to manage notifier:\n" + - "notifier stop Stop sending k8s event notifications to Slack (started by default)\n" + - "notifier start Start sending k8s event notifications to Slack\n" + - "notifier status Show running status of event notifier\n" + - "notifier showconfig Show BotKube configuration for event notifier\n\n" + - "Other Commands:\n" + - "help Show help\n" + - "ping Check connection health\n" - return helpMsg + allowedKubectl := strings.Join(kubecltCmdKeys, ", ") + helpMsg := ` +BotKube Help + +Usage: + @BotKube [--cluster-name ] + @BotKube notifier [stop|start|status|showconfig] + @BotKube ping [--cluster-name ] + +Description: + +Kubectl commands: + - Executes kubectl commands on k8s cluster and returns output. + + Example: + @BotKube get pods + @BotKube logs podname -n namespace + @BotKube get deployment --cluster-name cluster_name + + Allowed kubectl commands: + %s + +Cluster Status: + - List all available Kubernetes Clusters and check connection health. + - If flag specified, gives response from the specified cluster. + Example: + @BotKube ping + @BotKube ping --cluster-name mycluster + +Notifier commands: + - Commands to manage notifier (Runs only on configured channel %s). + + Example: + @BotKube notifier stop Stop sending k8s event notifications to Slack + @BotKube notifier start Start sending k8s event notifications to Slack + @BotKube notifier status Show running status of event notifier + @BotKube notifier showconfig Show BotKube configuration for event notifier + +Options: + --cluster-name Get cluster specific response +` + return fmt.Sprintf(helpMsg, allowedKubectl, channelName) } func printDefaultMsg() string { return unsupportedCmdMsg } -func runKubectlCommand(args []string) string { +func runKubectlCommand(args []string, clusterName string, isAuthChannel bool) string { // Use 'default' as a default namespace args = append([]string{"-n", "default"}, args...) // Remove unnecessary flags finalArgs := []string{} - for _, a := range args { - if a == "-f" || strings.HasPrefix(a, "--follow") { + checkFlag := false + for _, arg := range args { + if checkFlag { + if arg != clusterName { + return "" + } + checkFlag = false + continue + } + if arg == AbbrFollowFlag.String() || strings.HasPrefix(arg, FollowFlag.String()) { continue } - if a == "-w" || strings.HasPrefix(a, "--watch") { + if arg == AbbrWatchFlag.String() || strings.HasPrefix(arg, WatchFlag.String()) { continue } - finalArgs = append(finalArgs, a) + if strings.HasPrefix(arg, ClusterFlag.String()) { + if arg == ClusterFlag.String() { + checkFlag = true + } else if strings.SplitAfterN(arg, ClusterFlag.String()+"=", 2)[1] != clusterName { + return "" + } + isAuthChannel = true + continue + } + finalArgs = append(finalArgs, arg) + } + if isAuthChannel == false { + return "" } - cmd := exec.Command(kubectlBinary, finalArgs...) out, err := cmd.CombinedOutput() if err != nil { log.Logger.Error("Error in executing kubectl command: ", err) - return string(out) + err.Error() + return fmt.Sprintf("Cluster: %s\n%s", clusterName, string(out)+err.Error()) } - return string(out) + return fmt.Sprintf("Cluster: %s\n%s", clusterName, string(out)) } // TODO: Have a seperate cli which runs bot commands -func runNotifierCommand(args []string) string { - switch len(args) { - case 1: - if strings.ToLower(args[0]) == "help" { - return printHelp() - } - if strings.ToLower(args[0]) == "ping" { - return "pong" - } - case 2: - if args[0] != "notifier" { - return printDefaultMsg() - } - if args[1] == "start" { - config.Notify = true - log.Logger.Info("Notifier enabled") - return notifierStartMsg +func runNotifierCommand(args []string, clusterName string, isAuthChannel bool) string { + if isAuthChannel == false { + return "" + } + switch args[1] { + case Start.String(): + config.Notify = true + log.Logger.Info("Notifier enabled") + return fmt.Sprintf(notifierStartMsg, clusterName) + case Stop.String(): + config.Notify = false + log.Logger.Info("Notifier disabled") + return fmt.Sprintf(notifierStopMsg, clusterName) + case Status.String(): + if config.Notify == false { + return fmt.Sprintf("Notifications are off for cluster '%s'", clusterName) } - if args[1] == "stop" { - config.Notify = false - log.Logger.Info("Notifier disabled") - return notifierStopMsg + return fmt.Sprintf("Notifications are on for cluster '%s'", clusterName) + case ShowConfig.String(): + out, err := showControllerConfig() + if err != nil { + log.Logger.Error("Error in executing showconfig command: ", err) + return "Error in getting configuration!" } - if args[1] == "status" { - if config.Notify == false { - return "stopped" + return fmt.Sprintf("Showing config for cluster '%s'\n\n%s", clusterName, out) + } + return printDefaultMsg() +} + +func runPingCommand(args []string, clusterName string) string { + checkFlag := false + for _, arg := range args { + if checkFlag { + if arg != clusterName { + return "" } - return "running" + checkFlag = false + continue } - if args[1] == "showconfig" { - out, err := showControllerConfig() - if err != nil { - log.Logger.Error("Error in executing showconfig command: ", err) - return "Error in getting configuration!" + if strings.HasPrefix(arg, ClusterFlag.String()) { + if arg == ClusterFlag.String() { + checkFlag = true + } else if strings.SplitAfterN(arg, ClusterFlag.String()+"=", 2)[1] != clusterName { + return "" } - return out + continue } } - return printDefaultMsg() + return fmt.Sprintf("pong from cluster '%s'", clusterName) } func showControllerConfig() (string, error) { diff --git a/pkg/slack/slack.go b/pkg/slack/slack.go index af6434131..7e949a8d4 100644 --- a/pkg/slack/slack.go +++ b/pkg/slack/slack.go @@ -14,14 +14,15 @@ import ( type Bot struct { Token string AllowKubectl bool + ClusterName string ChannelName string - CheckChannel bool } // slackMessage contains message details to execute command and send back the result type slackMessage struct { ChannelID string BotID string + MessageType string InMessage string OutMessage string OutMsgLength int @@ -37,8 +38,8 @@ func NewSlackBot() *Bot { return &Bot{ Token: c.Communications.Slack.Token, AllowKubectl: c.Settings.AllowKubectl, + ClusterName: c.Settings.ClusterName, ChannelName: c.Communications.Slack.Channel, - CheckChannel: c.Settings.CheckChannel, } } @@ -55,6 +56,7 @@ func (b *Bot) Start() { go rtm.ManageConnection() for msg := range rtm.IncomingEvents { + isAuthChannel := false switch ev := msg.Data.(type) { case *slack.ConnectedEvent: logging.Logger.Debug("Connection Info: ", ev.Info) @@ -73,10 +75,9 @@ func (b *Bot) Start() { if !strings.HasPrefix(ev.Text, "<@"+botID+"> ") { continue } - // if config.settings.checkChannel is true // Serve only if current channel is in config - if b.CheckChannel && (b.ChannelName != info.Name) { - continue + if b.ChannelName == info.Name { + isAuthChannel = true } } } @@ -95,7 +96,7 @@ func (b *Bot) Start() { InMessage: inMessage, RTM: rtm, } - sm.HandleMessage(b.AllowKubectl) + sm.HandleMessage(b.AllowKubectl, b.ClusterName, b.ChannelName, isAuthChannel) case *slack.RTMError: logging.Logger.Errorf("Slack RMT error: %+v", ev.Error()) @@ -108,8 +109,8 @@ func (b *Bot) Start() { } } -func (sm *slackMessage) HandleMessage(allowkubectl bool) { - e := execute.NewDefaultExecutor(sm.InMessage, allowkubectl) +func (sm *slackMessage) HandleMessage(allowkubectl bool, clusterName, channelName string, isAuthChannel bool) { + e := execute.NewDefaultExecutor(sm.InMessage, allowkubectl, clusterName, channelName, isAuthChannel) sm.OutMessage = e.Execute() sm.OutMsgLength = len(sm.OutMessage) sm.Send() @@ -141,7 +142,11 @@ func (sm slackMessage) Send() { logging.Logger.Error("Error in uploading file:", err) } return + } else if sm.OutMsgLength == 0 { + logging.Logger.Info("Invalid request. Dumping the response") + return } + params := slack.PostMessageParameters{ AsUser: true, }