mirror of
https://github.com/valitydev/botkube.git
synced 2024-11-06 16:35:22 +00:00
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:
parent
aa72ab38a9
commit
e82a1d7f97
@ -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
51
design/multi-cluster.md
Normal 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
BIN
design/workflow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 78 KiB |
@ -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
|
||||
|
@ -49,7 +49,6 @@ type Slack struct {
|
||||
type Settings struct {
|
||||
ClusterName string
|
||||
AllowKubectl bool
|
||||
CheckChannel bool
|
||||
}
|
||||
|
||||
// New returns new Config
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user