-
Notifications
You must be signed in to change notification settings - Fork 283
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #29 from rusenask/feature/slack_integration
Feature/slack integration
- Loading branch information
Showing
793 changed files
with
288,078 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,237 @@ | ||
package bot | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"strings" | ||
|
||
"github.com/nlopes/slack" | ||
|
||
"github.com/rusenask/keel/provider/kubernetes" | ||
|
||
log "github.com/Sirupsen/logrus" | ||
) | ||
|
||
var ( | ||
botEventTextToResponse = map[string][]string{ | ||
"help": { | ||
`Here's a list of supported commands`, | ||
`- "get deployments" -> get a list of all deployments`, | ||
// `- "get deployments all" -> get a list of all deployments`, | ||
// `- "describe deployment <deployment>" -> get details for specified deployment`, | ||
}, | ||
} | ||
|
||
// static bot commands can be used straight away | ||
staticBotCommands = map[string]bool{ | ||
"get deployments": true, | ||
"get deployments all": true, | ||
} | ||
|
||
// dynamic bot command prefixes have to be matched | ||
dynamicBotCommandPrefixes = []string{"describe deployment"} | ||
) | ||
|
||
type Bot struct { | ||
id string // bot id | ||
name string // bot name | ||
|
||
users map[string]string | ||
|
||
msgPrefix string | ||
|
||
slackClient *slack.Client | ||
slackRTM *slack.RTM | ||
|
||
k8sImplementer kubernetes.Implementer | ||
|
||
ctx context.Context | ||
} | ||
|
||
func New(name, token string, k8sImplementer kubernetes.Implementer) *Bot { | ||
client := slack.New(token) | ||
|
||
return &Bot{ | ||
slackClient: client, | ||
k8sImplementer: k8sImplementer, | ||
name: name, | ||
} | ||
} | ||
|
||
// Start - start bot | ||
func (b *Bot) Start(ctx context.Context) error { | ||
|
||
// setting root context | ||
b.ctx = ctx | ||
|
||
users, err := b.slackClient.GetUsers() | ||
if err != nil { | ||
return err | ||
} | ||
|
||
b.users = map[string]string{} | ||
|
||
for _, user := range users { | ||
switch user.Name { | ||
case b.name: | ||
if user.IsBot { | ||
b.id = user.ID | ||
} | ||
default: | ||
continue | ||
} | ||
} | ||
if b.id == "" { | ||
return errors.New("could not find bot in the list of names, check if the bot is called \"" + b.name + "\" ") | ||
} | ||
|
||
b.msgPrefix = strings.ToLower("<@" + b.id + ">") | ||
|
||
go b.startInternal() | ||
|
||
return nil | ||
} | ||
|
||
func (b *Bot) startInternal() error { | ||
b.slackRTM = b.slackClient.NewRTM() | ||
|
||
go b.slackRTM.ManageConnection() | ||
|
||
for { | ||
select { | ||
case <-b.ctx.Done(): | ||
return nil | ||
|
||
case msg := <-b.slackRTM.IncomingEvents: | ||
switch ev := msg.Data.(type) { | ||
case *slack.HelloEvent: | ||
// Ignore hello | ||
|
||
case *slack.ConnectedEvent: | ||
// fmt.Println("Infos:", ev.Info) | ||
// fmt.Println("Connection counter:", ev.ConnectionCount) | ||
// Replace #general with your Channel ID | ||
// b.slackRTM.SendMessage(b.slackRTM.NewOutgoingMessage("Hello world", "#general")) | ||
|
||
case *slack.MessageEvent: | ||
b.handleMessage(ev) | ||
case *slack.PresenceChangeEvent: | ||
// fmt.Printf("Presence Change: %v\n", ev) | ||
|
||
// case *slack.LatencyReport: | ||
// fmt.Printf("Current latency: %v\n", ev.Value) | ||
|
||
case *slack.RTMError: | ||
fmt.Printf("Error: %s\n", ev.Error()) | ||
|
||
case *slack.InvalidAuthEvent: | ||
fmt.Printf("Invalid credentials") | ||
return fmt.Errorf("invalid credentials") | ||
|
||
default: | ||
|
||
// Ignore other events.. | ||
// fmt.Printf("Unexpected: %v\n", msg.Data) | ||
} | ||
} | ||
} | ||
} | ||
|
||
func (b *Bot) handleMessage(event *slack.MessageEvent) { | ||
if event.BotID != "" || event.User == "" || event.SubType == "bot_message" { | ||
log.WithFields(log.Fields{ | ||
"event_bot_ID": event.BotID, | ||
"event_user": event.User, | ||
"event_subtype": event.SubType, | ||
}).Info("handleMessage: ignoring message") | ||
return | ||
} | ||
|
||
eventText := strings.Trim(strings.ToLower(event.Text), " \n\r") | ||
|
||
// All messages past this point are directed to @gopher itself | ||
if !b.isBotMessage(event, eventText) { | ||
log.Info("not a bot message") | ||
return | ||
} | ||
|
||
eventText = b.trimBot(eventText) | ||
|
||
// Responses that are just a canned string response | ||
if responseLines, ok := botEventTextToResponse[eventText]; ok { | ||
response := strings.Join(responseLines, "\n") | ||
b.respond(event, formatAsSnippet(response)) | ||
return | ||
} | ||
|
||
if b.isCommand(event, eventText) { | ||
b.handleCommand(event, eventText) | ||
return | ||
} | ||
|
||
log.WithFields(log.Fields{ | ||
"name": b.name, | ||
"bot_id": b.id, | ||
"command": eventText, | ||
"untrimmed": strings.Trim(strings.ToLower(event.Text), " \n\r"), | ||
}).Debug("handleMessage: bot couldn't recognise command") | ||
} | ||
|
||
func (b *Bot) isCommand(event *slack.MessageEvent, eventText string) bool { | ||
if staticBotCommands[eventText] { | ||
return true | ||
} | ||
|
||
for _, prefix := range dynamicBotCommandPrefixes { | ||
if strings.HasPrefix(eventText, prefix) { | ||
return true | ||
} | ||
} | ||
|
||
return false | ||
} | ||
|
||
func (b *Bot) handleCommand(event *slack.MessageEvent, eventText string) { | ||
switch eventText { | ||
case "get deployments": | ||
log.Info("getting deployments") | ||
response := b.deploymentsResponse(Filter{}) | ||
b.respond(event, formatAsSnippet(response)) | ||
return | ||
} | ||
|
||
log.Info("command not found") | ||
} | ||
|
||
func (b *Bot) respond(event *slack.MessageEvent, response string) { | ||
b.slackRTM.SendMessage(b.slackRTM.NewOutgoingMessage(response, event.Channel)) | ||
} | ||
|
||
func (b *Bot) isBotMessage(event *slack.MessageEvent, eventText string) bool { | ||
prefixes := []string{ | ||
b.msgPrefix, | ||
"keel", | ||
} | ||
|
||
for _, p := range prefixes { | ||
if strings.HasPrefix(eventText, p) { | ||
return true | ||
} | ||
} | ||
|
||
// Direct message channels always starts with 'D' | ||
return strings.HasPrefix(event.Channel, "D") | ||
} | ||
|
||
func (b *Bot) trimBot(msg string) string { | ||
msg = strings.Replace(msg, strings.ToLower(b.msgPrefix), "", 1) | ||
msg = strings.TrimPrefix(msg, b.name) | ||
msg = strings.Trim(msg, " :\n") | ||
|
||
return msg | ||
} | ||
|
||
func formatAsSnippet(response string) string { | ||
return "```" + response + "```" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
package bot | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
|
||
"github.com/rusenask/keel/bot/formatter" | ||
|
||
"k8s.io/client-go/pkg/apis/extensions/v1beta1" | ||
|
||
log "github.com/Sirupsen/logrus" | ||
) | ||
|
||
type Filter struct { | ||
Namespace string | ||
All bool // keel or not | ||
} | ||
|
||
// deployments - gets all deployments | ||
func (b *Bot) deployments() ([]v1beta1.Deployment, error) { | ||
deploymentLists := []*v1beta1.DeploymentList{} | ||
|
||
n, err := b.k8sImplementer.Namespaces() | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
for _, n := range n.Items { | ||
l, err := b.k8sImplementer.Deployments(n.GetName()) | ||
if err != nil { | ||
log.WithFields(log.Fields{ | ||
"error": err, | ||
"namespace": n.GetName(), | ||
}).Error("provider.kubernetes: failed to list deployments") | ||
continue | ||
} | ||
deploymentLists = append(deploymentLists, l) | ||
} | ||
|
||
impacted := []v1beta1.Deployment{} | ||
|
||
for _, deploymentList := range deploymentLists { | ||
for _, deployment := range deploymentList.Items { | ||
impacted = append(impacted, deployment) | ||
} | ||
} | ||
|
||
return impacted, nil | ||
} | ||
|
||
func (b *Bot) deploymentsResponse(filter Filter) string { | ||
deps, err := b.deployments() | ||
if err != nil { | ||
return fmt.Sprintf("got error while fetching deployments: %s", err) | ||
} | ||
log.Debugf("%d deployments fetched, formatting", len(deps)) | ||
buf := &bytes.Buffer{} | ||
|
||
DeploymentCtx := formatter.Context{ | ||
Output: buf, | ||
Format: formatter.NewDeploymentsFormat(formatter.TableFormatKey, false), | ||
} | ||
err = formatter.DeploymentWrite(DeploymentCtx, convertToInternal(deps)) | ||
|
||
if err != nil { | ||
return fmt.Sprintf(" got error while formatting deployments: %s", err) | ||
} | ||
|
||
return buf.String() | ||
} | ||
|
||
func convertToInternal(deployments []v1beta1.Deployment) []formatter.Deployment { | ||
formatted := []formatter.Deployment{} | ||
for _, d := range deployments { | ||
|
||
formatted = append(formatted, formatter.Deployment{ | ||
Namespace: d.Namespace, | ||
Name: d.Name, | ||
Replicas: d.Status.Replicas, | ||
AvailableReplicas: d.Status.AvailableReplicas, | ||
Images: getImages(&d), | ||
}) | ||
} | ||
return formatted | ||
} | ||
|
||
func getImages(deployment *v1beta1.Deployment) []string { | ||
var images []string | ||
for _, c := range deployment.Spec.Template.Spec.Containers { | ||
images = append(images, c.Image) | ||
} | ||
|
||
return images | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
package formatter | ||
|
||
import ( | ||
"strings" | ||
) | ||
|
||
const ( | ||
imageHeader = "IMAGE" | ||
createdSinceHeader = "CREATED" | ||
createdAtHeader = "CREATED AT" | ||
sizeHeader = "SIZE" | ||
labelsHeader = "LABELS" | ||
nameHeader = "NAME" | ||
driverHeader = "DRIVER" | ||
scopeHeader = "SCOPE" | ||
) | ||
|
||
type subContext interface { | ||
FullHeader() string | ||
AddHeader(header string) | ||
} | ||
|
||
// HeaderContext provides the subContext interface for managing headers | ||
type HeaderContext struct { | ||
header []string | ||
} | ||
|
||
// FullHeader returns the header as a string | ||
func (c *HeaderContext) FullHeader() string { | ||
if c.header == nil { | ||
return "" | ||
} | ||
return strings.Join(c.header, "\t") | ||
} | ||
|
||
// AddHeader adds another column to the header | ||
func (c *HeaderContext) AddHeader(header string) { | ||
if c.header == nil { | ||
c.header = []string{} | ||
} | ||
c.header = append(c.header, strings.ToUpper(header)) | ||
} | ||
|
||
func stripNamePrefix(ss []string) []string { | ||
sss := make([]string, len(ss)) | ||
for i, s := range ss { | ||
sss[i] = s[1:] | ||
} | ||
|
||
return sss | ||
} |
Oops, something went wrong.