Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make event notifications actionable #803

Merged
merged 5 commits into from
Oct 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions cmd/botkube/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/discovery"
cacheddiscovery "k8s.io/client-go/discovery/cached"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
Expand Down Expand Up @@ -143,7 +143,10 @@ func run() error {
resourceNameNormalizerFunc = resourceNameNormalizer.Normalize
}

// Create executor factor
cmdGuard := kubectl.NewCommandGuard(logger.WithField(componentLogFieldKey, "Command Guard"), discoveryCli)
commander := kubectl.NewCommander(logger.WithField(componentLogFieldKey, "Commander"), kcMerger, cmdGuard)

// Create executor factory
cfgManager := config.NewManager(logger.WithField(componentLogFieldKey, "Config manager"), conf.Settings.PersistentConfig, k8sCli)
executorFactory := execute.NewExecutorFactory(
execute.DefaultExecutorFactoryParams{
Expand All @@ -156,6 +159,7 @@ func run() error {
CfgManager: cfgManager,
AnalyticsReporter: reporter,
NamespaceLister: k8sCli.CoreV1().Namespaces(),
CommandGuard: cmdGuard,
},
)

Expand Down Expand Up @@ -194,7 +198,7 @@ func run() error {
}

if commGroupCfg.SocketSlack.Enabled {
sb, err := bot.NewSocketSlack(commGroupLogger.WithField(botLogFieldKey, "SocketSlack"), commGroupName, commGroupCfg.SocketSlack, executorFactory, reporter)
sb, err := bot.NewSocketSlack(commGroupLogger.WithField(botLogFieldKey, "SocketSlack"), commGroupName, commGroupCfg.SocketSlack, executorFactory, commander, reporter)
if err != nil {
return reportFatalError("while creating SocketSlack bot", err)
}
Expand Down Expand Up @@ -393,7 +397,7 @@ func getK8sClients(cfg *rest.Config) (dynamic.Interface, discovery.DiscoveryInte
return nil, nil, nil, fmt.Errorf("while creating dynamic K8s client: %w", err)
}

discoCacheClient := cacheddiscovery.NewMemCacheClient(discoveryClient)
discoCacheClient := memory.NewMemCacheClient(discoveryClient)
mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoCacheClient)
return dynamicK8sCli, discoveryClient, mapper, nil
}
Expand Down
2 changes: 1 addition & 1 deletion helm/botkube/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Controller for the Botkube Slack app which helps you monitor your Kubernetes clu
| [executors.kubectl-read-only.kubectl.namespaces.exclude](./values.yaml#L254) | list | `[]` | List of ignored Kubernetes Namespace. It can also contain a regex expressions: `- "test-.*"` - to specify all Namespaces. |
| [executors.kubectl-read-only.kubectl.enabled](./values.yaml#L256) | bool | `false` | If true, enables `kubectl` commands execution. |
| [executors.kubectl-read-only.kubectl.commands.verbs](./values.yaml#L260) | list | `["api-resources","api-versions","cluster-info","describe","explain","get","logs","top"]` | Configures which `kubectl` methods are allowed. |
| [executors.kubectl-read-only.kubectl.commands.resources](./values.yaml#L262) | list | `["deployments","pods","namespaces","daemonsets","statefulsets","storageclasses","nodes","configmaps","services"]` | Configures which K8s resource are allowed. |
| [executors.kubectl-read-only.kubectl.commands.resources](./values.yaml#L262) | list | `["deployments","pods","namespaces","daemonsets","statefulsets","storageclasses","nodes","configmaps","services","ingresses"]` | Configures which K8s resource are allowed. |
| [executors.kubectl-read-only.kubectl.defaultNamespace](./values.yaml#L264) | string | `"default"` | Configures the default Namespace for executing Botkube `kubectl` commands. If not set, uses the 'default'. |
| [executors.kubectl-read-only.kubectl.restrictAccess](./values.yaml#L266) | bool | `false` | If true, enables commands execution from configured channel only. |
| [existingCommunicationsSecretName](./values.yaml#L277) | string | `""` | Configures existing Secret with communication settings. It MUST be in the `botkube` Namespace. To reload Botkube once it changes, add label `botkube.io/config-watch: "true"`. |
Expand Down
2 changes: 1 addition & 1 deletion helm/botkube/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ executors:
# -- Configures which `kubectl` methods are allowed.
verbs: ["api-resources", "api-versions", "cluster-info", "describe", "explain", "get", "logs", "top"]
# -- Configures which K8s resource are allowed.
resources: ["deployments", "pods", "namespaces", "daemonsets", "statefulsets", "storageclasses", "nodes", "configmaps", "services"]
resources: ["deployments", "pods", "namespaces", "daemonsets", "statefulsets", "storageclasses", "nodes", "configmaps", "services", "ingresses"]
# -- Configures the default Namespace for executing Botkube `kubectl` commands. If not set, uses the 'default'.
defaultNamespace: default
# -- If true, enables commands execution from configured channel only.
Expand Down
24 changes: 24 additions & 0 deletions pkg/bot/interactive/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package interactive

// EventCommandsSection defines a structure of commands for a given event.
func EventCommandsSection(cmdPrefix string, optionItems []OptionItem) Section {
section := Section{
Selects: Selects{
ID: "",
Items: []Select{
{
Name: "Run command...",
Command: cmdPrefix,
OptionGroups: []OptionGroup{
{
Name: "Supported commands",
Options: optionItems,
},
},
},
},
},
}

return section
}
23 changes: 23 additions & 0 deletions pkg/bot/interactive/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ type Section struct {
Buttons Buttons
MultiSelect MultiSelect
Selects Selects
TextFields TextFields
Context ContextItems
}

// ContextItems holds context items.
type ContextItems []ContextItem

// TextFields holds text field items.
type TextFields []TextField

// TextField holds a text field data.
type TextField struct {
Text string
}

// IsDefined returns true if there are any context items defined.
func (c ContextItems) IsDefined() bool {
return len(c) > 0
}

// ContextItem holds context item.
type ContextItem struct {
Text string
}

// Selects holds multiple Select objects.
Expand Down
2 changes: 1 addition & 1 deletion pkg/bot/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ func (b *Slack) send(msg slackMessage, req string, resp interactive.Message, onl
// SendEvent sends event notification to slack
func (b *Slack) SendEvent(ctx context.Context, event events.Event, eventSources []string) error {
b.log.Debugf("Sending to Slack: %+v", event)
attachment := b.renderer.RenderEventMessage(event)
attachment := b.renderer.RenderLegacyEventMessage(event)

errs := multierror.New()
for _, channelName := range b.getChannelsToNotify(event, eventSources) {
Expand Down
150 changes: 144 additions & 6 deletions pkg/bot/slack_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strconv"
"strings"
"time"

"github.com/slack-go/slack"

Expand All @@ -19,6 +20,14 @@ const (
cmdButtonActionIDPrefix = "cmd:"
)

var emojiForLevel = map[config.Level]string{
config.Info: ":large_green_circle:",
config.Warn: ":warning:",
config.Debug: ":information_source:",
config.Error: ":x:",
config.Critical: ":x:",
}

// SlackRenderer provides functionality to render Slack specific messages from a generic models.
type SlackRenderer struct {
notification config.Notification
Expand All @@ -29,17 +38,17 @@ func NewSlackRenderer(notificationType config.Notification) *SlackRenderer {
return &SlackRenderer{notification: notificationType}
}

// RenderEventMessage returns Slack message based on a given event.
func (b *SlackRenderer) RenderEventMessage(event events.Event) slack.Attachment {
// RenderLegacyEventMessage returns Slack message based on a given event.
func (b *SlackRenderer) RenderLegacyEventMessage(event events.Event) slack.Attachment {
var attachment slack.Attachment

switch b.notification.Type {
case config.LongNotification:
attachment = b.longNotification(event)
attachment = b.legacyLongNotification(event)
case config.ShortNotification:
fallthrough
default:
attachment = b.shortNotification(event)
attachment = b.legacyShortNotification(event)
}

// Add timestamp
Expand All @@ -51,6 +60,26 @@ func (b *SlackRenderer) RenderEventMessage(event events.Event) slack.Attachment
return attachment
}

// RenderEventMessage returns Slack interactive message based on a given event.
func (b *SlackRenderer) RenderEventMessage(event events.Event, additionalSections ...interactive.Section) interactive.Message {
var sections []interactive.Section

switch b.notification.Type {
case config.LongNotification:
sections = append(sections, b.longNotificationSection(event))
case config.ShortNotification:
fallthrough
default:
sections = append(sections, b.shortNotificationSection(event))
}

if len(additionalSections) > 0 {
sections = append(sections, additionalSections...)
}

return interactive.Message{Sections: sections}
}

// RenderModal returns a modal request view based on a given message.
func (b *SlackRenderer) RenderModal(msg interactive.Message) slack.ModalViewRequest {
title := msg.Header
Expand Down Expand Up @@ -174,6 +203,10 @@ func (b *SlackRenderer) renderSection(in interactive.Section) []slack.Block {
out = append(out, b.mdTextSection(in.Description))
}

if len(in.TextFields) > 0 {
out = append(out, b.renderTextFields(in.TextFields))
}

if in.Body.Plaintext != "" {
out = append(out, b.mdTextSection(in.Body.Plaintext))
}
Expand All @@ -192,9 +225,49 @@ func (b *SlackRenderer) renderSection(in interactive.Section) []slack.Block {
out = append(out, b.renderSelects(in.Selects))
}

if len(in.Context) > 0 {
out = append(out, b.renderContext(in.Context)...)
}

return out
}

func (b *SlackRenderer) renderTextFields(in interactive.TextFields) slack.Block {
var textBlockObjs []*slack.TextBlockObject
for _, item := range in {
if item.Text == "" {
// Skip empty sections
continue
}

textBlockObjs = append(textBlockObjs, slack.NewTextBlockObject(slack.MarkdownType, item.Text, false, false))
}

return slack.NewSectionBlock(
nil,
textBlockObjs,
nil,
)
}

func (b *SlackRenderer) renderContext(in []interactive.ContextItem) []slack.Block {
var blocks []slack.Block

for _, item := range in {
if item.Text == "" {
// Skip empty sections
continue
}

blocks = append(blocks, slack.NewContextBlock(
"",
slack.NewTextBlockObject(slack.MarkdownType, item.Text, false, false),
))
}

return blocks
}

// renderButtons renders button section.
//
// 1. With description, renders one per row. For example:
Expand Down Expand Up @@ -297,7 +370,72 @@ func (*SlackRenderer) plainTextBlock(msg string) *slack.TextBlockObject {
return slack.NewTextBlockObject(slack.PlainTextType, msg, false, false)
}

func (b *SlackRenderer) longNotification(event events.Event) slack.Attachment {
func (b *SlackRenderer) longNotificationSection(event events.Event) interactive.Section {
section := b.baseNotificationSection(event)
section.TextFields = interactive.TextFields{
{Text: fmt.Sprintf("*Kind:* %s", event.Kind)},
{Text: fmt.Sprintf("*Name:* %s", event.Name)},
}
section.TextFields = b.appendTextFieldIfNotEmpty(section.TextFields, "Namespace", event.Namespace)
section.TextFields = b.appendTextFieldIfNotEmpty(section.TextFields, "Reason", event.Reason)
section.TextFields = b.appendTextFieldIfNotEmpty(section.TextFields, "Action", event.Action)
section.TextFields = b.appendTextFieldIfNotEmpty(section.TextFields, "Cluster", event.Cluster)

// Messages, Recommendations and Warnings formatted as bullet point lists.
section.Body.Plaintext = formatx.BulletPointEventAttachments(event)

return section
}

func (b *SlackRenderer) appendTextFieldIfNotEmpty(fields []interactive.TextField, title, in string) []interactive.TextField {
if in == "" {
return fields
}
return append(fields, interactive.TextField{
Text: fmt.Sprintf("*%s:* %s", title, in),
})
}

func (b *SlackRenderer) shortNotificationSection(event events.Event) interactive.Section {
section := b.baseNotificationSection(event)

header := formatx.ShortNotificationHeader(event)
attachments := formatx.BulletPointEventAttachments(event)
prefix := ""
if attachments != "" {
prefix = "\n"
}

section.Base.Description = fmt.Sprintf(
"%s\n%s%s",
header,
prefix,
attachments,
)

return section
}

func (b *SlackRenderer) baseNotificationSection(event events.Event) interactive.Section {
emoji := emojiForLevel[event.Level]
section := interactive.Section{
Base: interactive.Base{
Header: fmt.Sprintf("%s %s", emoji, event.Title),
},
}

if !event.TimeStamp.IsZero() {
fallbackTimestampText := event.TimeStamp.Format(time.RFC1123)
timestampText := fmt.Sprintf("<!date^%d^{date_num} {time_secs}|%s>", event.TimeStamp.Unix(), fallbackTimestampText)
section.Context = []interactive.ContextItem{{
Text: timestampText,
}}
}

return section
}

func (b *SlackRenderer) legacyLongNotification(event events.Event) slack.Attachment {
attachment := slack.Attachment{
Pretext: fmt.Sprintf("*%s*", event.Title),
Fields: []slack.AttachmentField{
Expand Down Expand Up @@ -338,7 +476,7 @@ func (b *SlackRenderer) appendIfNotEmpty(fields []slack.AttachmentField, in stri
})
}

func (b *SlackRenderer) shortNotification(event events.Event) slack.Attachment {
func (b *SlackRenderer) legacyShortNotification(event events.Event) slack.Attachment {
return slack.Attachment{
Title: event.Title,
Fields: []slack.AttachmentField{
Expand Down
Loading