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

add slack alert implementation #475

Merged
merged 4 commits into from
Aug 25, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
18 changes: 18 additions & 0 deletions probes/alerting/notifier/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/cloudprober/cloudprober/common/strtemplate"
"github.com/cloudprober/cloudprober/logger"
"github.com/cloudprober/cloudprober/probes/alerting/notifier/pagerduty"
"github.com/cloudprober/cloudprober/probes/alerting/notifier/slack"
configpb "github.com/cloudprober/cloudprober/probes/alerting/proto"
"github.com/cloudprober/cloudprober/targets/endpoint"
)
Expand Down Expand Up @@ -54,6 +55,7 @@ type Notifier struct {
cmdNotifier *commandNotifier
emailNotifier *emailNotifier
pagerdutyNotifier *pagerduty.Client
slackNotifier *slack.Client
}

// AlertInfo contains information about an alert.
Expand Down Expand Up @@ -141,6 +143,14 @@ func (n *Notifier) Notify(ctx context.Context, alertInfo *AlertInfo) error {
}
}

if n.slackNotifier != nil {
slackErr := n.slackNotifier.Notify(ctx, fields)
if slackErr != nil {
n.l.Errorf("Error sending Slack message: %v", slackErr)
err = errors.Join(err, slackErr)
}
}

return err
}

Expand Down Expand Up @@ -195,5 +205,13 @@ func New(alertcfg *configpb.AlertConf, l *logger.Logger) (*Notifier, error) {
n.pagerdutyNotifier = pd
}

if n.alertcfg.GetNotify().GetSlack() != nil {
slack, err := slack.New(n.alertcfg.Notify.GetSlack(), l)
if err != nil {
return nil, fmt.Errorf("error configuring Slack notifier: %v", err)
}
n.slackNotifier = slack
}

return n, nil
}
110 changes: 110 additions & 0 deletions probes/alerting/notifier/slack/slack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Package slack implements slack notifications for Cloudprober alert events.
package slack

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"

"github.com/cloudprober/cloudprober/logger"
configpb "github.com/cloudprober/cloudprober/probes/alerting/proto"
)

const (
// DEFAULT_SLACK_WEBHOOK_URL_ENV_VAR is the default environment variable
// to use for the Slack webhook URL.
DEFAULT_SLACK_WEBHOOK_URL_ENV_VAR = "SLACK_WEBHOOK_URL"
)

// Client is a Slack client.
type Client struct {
httpClient *http.Client
logger *logger.Logger
webhookURL string
}

// New creates a new Slack client.
func New(slackcfg *configpb.Slack, l *logger.Logger) (*Client, error) {
webhookURL, err := lookupWebhookUrl(slackcfg)
if err != nil {
return nil, err
}

return &Client{
httpClient: &http.Client{},
manugarg marked this conversation as resolved.
Show resolved Hide resolved
logger: l,
webhookURL: webhookURL,
}, nil
}

// lookupWebhookUrl looks up the webhook URL to use for the Slack client,
// in order of precendence:
// 1. Webhook URL in the config
// 2. Webhook URL environment variable
func lookupWebhookUrl(slackcfg *configpb.Slack) (string, error) {
// check if the webhook URL is set in the config
if slackcfg.GetWebhookUrl() != "" {
return slackcfg.GetWebhookUrl(), nil
}

// check if the environment variable is set for the webhook URL
if webhookURL, exists := os.LookupEnv(webhookUrlEnvVar(slackcfg)); exists {
return webhookURL, nil
}

return "", fmt.Errorf("no Slack webhook URL found")
}

// webhookUrlEnvVar returns the environment variable to use for the Slack
func webhookUrlEnvVar(slackcfg *configpb.Slack) string {
if slackcfg.GetWebhookUrlEnvVar() != "" {
return slackcfg.GetWebhookUrlEnvVar()
}

return DEFAULT_SLACK_WEBHOOK_URL_ENV_VAR
}

// webhookMessage is the message that is sent to the Slack webhook.
type webhookMessage struct {
Text string `json:"text"`
}

// Notify sends a notification to Slack.
func (c *Client) Notify(ctx context.Context, alertFields map[string]string) error {
message := createMessage(alertFields)

jsonBody, err := json.Marshal(message)
if err != nil {
return err
}

req, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}

req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

// check status code, return error if not 200
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Slack webhook returned status code %d", resp.StatusCode)
}

return nil
}

// createMessage creates a new Slack webhook message, from the alertFields
func createMessage(alertFields map[string]string) webhookMessage {
return webhookMessage{
Text: alertFields["details"],
manugarg marked this conversation as resolved.
Show resolved Hide resolved
}
}
127 changes: 127 additions & 0 deletions probes/alerting/notifier/slack/slack_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package slack

import (
"context"
"net/http"
"net/http/httptest"
"testing"

configpb "github.com/cloudprober/cloudprober/probes/alerting/proto"
)

func TestSlackNew(t *testing.T) {
tests := map[string]struct {
cfg *configpb.Slack
envVars map[string]string
wantWebhook string
}{
"valid config": {
cfg: &configpb.Slack{
WebhookUrl: "test-webhook-url",
},
envVars: map[string]string{},
wantWebhook: "test-webhook-url",
},
"valid config with env var": {
cfg: &configpb.Slack{
WebhookUrl: "test-webhook-url",
},
envVars: map[string]string{
"SLACK_WEBHOOK_URL": "test-webhook-url-env-var",
},
wantWebhook: "test-webhook-url",
},
"env var": {
cfg: &configpb.Slack{},
envVars: map[string]string{
"SLACK_WEBHOOK_URL": "test-webhook-url-env-var",
},
wantWebhook: "test-webhook-url-env-var",
},
"env var override": {
cfg: &configpb.Slack{
WebhookUrlEnvVar: "SLACK_WEBHOOK_URL_OVERRIDE",
},
envVars: map[string]string{
"SLACK_WEBHOOK_URL_OVERRIDE": "test-webhook-url-env-var",
"SLACK_WEBHOOK_URL": "test-webhook-url-env-var-2",
},
wantWebhook: "test-webhook-url-env-var",
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
for k, v := range tc.envVars {
t.Setenv(k, v)
}

c, err := New(tc.cfg, nil)
if err != nil {
t.Errorf("New() error = %v", err)
}

if c.webhookURL != tc.wantWebhook {
t.Errorf("New() = %v, want %v", c.webhookURL, tc.wantWebhook)
}
})
}
}

func TestSlackCreateWebhookMessage(t *testing.T) {
tests := map[string]struct {
alertFields map[string]string
want webhookMessage
}{
"valid": {
alertFields: map[string]string{
"details": "test-details",
},
want: webhookMessage{
Text: "test-details",
},
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := createMessage(tc.alertFields)
if got != tc.want {
t.Errorf("createMessage() = %v, want %v", got, tc.want)
}
})
}
}

func TestSlackNotify(t *testing.T) {
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer httpServer.Close()

tests := map[string]struct {
alertFields map[string]string
wantErr bool
}{
"valid": {
alertFields: map[string]string{
"summary": "test-summary",
},
wantErr: false,
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
c := &Client{
httpClient: httpServer.Client(),
webhookURL: httpServer.URL,
}

err := c.Notify(context.Background(), tc.alertFields)
if (err != nil) != tc.wantErr {
t.Errorf("Notify() error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}