Issue #37: Added multi-cluster support for displaying cluster specific information.

Issue #16: Added cluster-name in kubectl, notifier and ping messages from botkube.
This commit is contained in:
mugdha-adhav 2019-02-20 13:24:55 +05:30
parent aa72ab38a9
commit e82a1d7f97
8 changed files with 252 additions and 98 deletions

View File

@ -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

51
design/multi-cluster.md Normal file
View File

@ -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.

BIN
design/workflow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -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

View File

@ -49,7 +49,6 @@ type Slack struct {
type Settings struct {
ClusterName string
AllowKubectl bool
CheckChannel bool
}
// New returns new Config

View File

@ -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) {

View File

@ -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,112 +106,172 @@ 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 <kubectl command without `kubectl` prefix>\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 <kubectl command without kubectl prefix> [--cluster-name <cluster_name>]
@BotKube notifier [stop|start|status|showconfig]
@BotKube ping [--cluster-name <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 a == "-w" || strings.HasPrefix(a, "--watch") {
if arg == AbbrFollowFlag.String() || strings.HasPrefix(arg, FollowFlag.String()) {
continue
}
finalArgs = append(finalArgs, a)
if arg == AbbrWatchFlag.String() || strings.HasPrefix(arg, WatchFlag.String()) {
continue
}
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()
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 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
}
if args[1] == "stop" {
config.Notify = false
log.Logger.Info("Notifier disabled")
return notifierStopMsg
}
if args[1] == "status" {
if config.Notify == false {
return "stopped"
}
return "running"
}
if args[1] == "showconfig" {
out, err := showControllerConfig()
if err != nil {
log.Logger.Error("Error in executing showconfig command: ", err)
return "Error in getting configuration!"
}
return out
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!"
}
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 ""
}
checkFlag = false
continue
}
if strings.HasPrefix(arg, ClusterFlag.String()) {
if arg == ClusterFlag.String() {
checkFlag = true
} else if strings.SplitAfterN(arg, ClusterFlag.String()+"=", 2)[1] != clusterName {
return ""
}
continue
}
}
return fmt.Sprintf("pong from cluster '%s'", clusterName)
}
func showControllerConfig() (string, error) {
configPath := os.Getenv("CONFIG_PATH")
configFile := filepath.Join(configPath, config.ConfigFileName)

View File

@ -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,
}