diff --git a/README.md b/README.md index ead3a994..683106a3 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,18 @@ title | `alert.feishu.webhook` | FeiShu bot webhook URL | | `alert.feishu.title` | Customized title in message | +#### Zenduty + +

+ +

+If you want to enable Zenduty, provide IntegrationKey with optional alert type + +| Parameter | Description | +|:-------------------------------|:----------------------------| +| `alert.zenduty.integrationKey` | Zenduty integration Key | +| `alert.zenduty.alertType` | Optional alert type of incident: critical, acknowledged, resolved, error, warning, info (default: critical) | + #### Custom webhook If you want to enable custom webhook, provide url with optional headers and diff --git a/alertmanager/alertmanager.go b/alertmanager/alertmanager.go index 62bc428b..8bf1cca9 100644 --- a/alertmanager/alertmanager.go +++ b/alertmanager/alertmanager.go @@ -17,6 +17,7 @@ import ( "github.com/abahmed/kwatch/alertmanager/teams" "github.com/abahmed/kwatch/alertmanager/telegram" "github.com/abahmed/kwatch/alertmanager/webhook" + "github.com/abahmed/kwatch/alertmanager/zenduty" "github.com/abahmed/kwatch/config" "github.com/abahmed/kwatch/event" "github.com/sirupsen/logrus" @@ -67,7 +68,10 @@ func (a *AlertManager) Init( pvdr = feishu.NewFeiShu(v, appCfg) } else if lowerCaseKey == "webhook" { pvdr = webhook.NewWebhook(v, appCfg) + } else if lowerCaseKey == "zenduty" { + pvdr = zenduty.NewZenduty(v, appCfg) } + if !reflect.ValueOf(pvdr).IsNil() { a.providers = append(a.providers, pvdr) } diff --git a/alertmanager/alertmanager_test.go b/alertmanager/alertmanager_test.go index 46afa511..4ca311ab 100644 --- a/alertmanager/alertmanager_test.go +++ b/alertmanager/alertmanager_test.go @@ -89,6 +89,9 @@ func TestGetProviders(t *testing.T) { "webhook": { "url": "test", }, + "zenduty": { + "integrationKey": "test", + }, } alertmanager := AlertManager{} diff --git a/alertmanager/zenduty/zenduty.go b/alertmanager/zenduty/zenduty.go new file mode 100644 index 00000000..c25fc56d --- /dev/null +++ b/alertmanager/zenduty/zenduty.go @@ -0,0 +1,150 @@ +package zenduty + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + + "github.com/abahmed/kwatch/config" + "github.com/abahmed/kwatch/constant" + "github.com/abahmed/kwatch/event" + "github.com/sirupsen/logrus" +) + +const ( + defaultZendutyTitle = "kwatch detected a crash in pod: %s" + defaultZendutyText = "There is an issue with container (%s) in pod (%s)" + zendutyAPIURL = "https://www.zenduty.com/api/events" +) + +var AlertTypes = []string{ + "critical", + "acknowledged", + "resolved", + "error", + "warning", + "info", +} + +type Zenduty struct { + integrationkey string + url string + alertType string + + // reference for general app configuration + appCfg *config.App +} + +type zendutyPayload struct { + Message string `json:"message"` + Summary string `json:"summary"` + AlertType string `json:"alert_type"` +} + +// NewZenduty returns new zenduty instance +func NewZenduty(config map[string]interface{}, appCfg *config.App) *Zenduty { + integrationKey, ok := config["integrationKey"].(string) + if !ok || len(integrationKey) == 0 { + logrus.Warnf("initializing zenduty with empty webhook url") + return nil + } + + logrus.Infof("initializing zenduty with secret apikey") + + // If alert type is not provided, or provided with invalid value + // it will fallback to critical type + alertType, ok := config["alertType"].(string) + if !ok || !slices.Contains(AlertTypes, alertType) { + alertType = "critical" + } + + return &Zenduty{ + integrationkey: integrationKey, + url: zendutyAPIURL, + alertType: alertType, + appCfg: appCfg, + } +} + +// Name returns name of the provider +func (m *Zenduty) Name() string { + return "Zenduty" +} + +// SendMessage sends text message to the provider +func (m *Zenduty) SendMessage(msg string) error { + return nil +} + +// SendEvent sends event to the provider +func (m *Zenduty) SendEvent(e *event.Event) error { + return m.sendAPI(m.buildMessage(e)) +} + +// sendAPI sends http request to Zenduty API +func (m *Zenduty) sendAPI(content []byte) error { + client := &http.Client{} + buffer := bytes.NewBuffer(content) + url := m.url + "/" + m.integrationkey + "/" + request, err := http.NewRequest(http.MethodPost, url, buffer) + if err != nil { + return err + } + + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode != 201 { + body, _ := io.ReadAll(response.Body) + return fmt.Errorf( + "call to zenduty alert returned status code %d: %s", + response.StatusCode, + string(body)) + } + + return nil +} + +func (m *Zenduty) buildMessage(e *event.Event) []byte { + payload := zendutyPayload{ + AlertType: m.alertType, + } + + logs := constant.DefaultLogs + if len(e.Logs) > 0 { + logs = (e.Logs) + } + + events := constant.DefaultEvents + if len(e.Events) > 0 { + events = (e.Events) + } + + payload.Message = fmt.Sprintf(defaultZendutyTitle, e.Name) + payload.Summary = fmt.Sprintf( + "An alert has been triggered for\n\n"+ + "cluster: %s\n"+ + "Pod Name: %s\n"+ + "Container: %s\n"+ + "Namespace: %s\n"+ + "Reason: %s\n\n"+ + "Events:\n%s\n\n"+ + "Logs:\n%s\n\n", + m.appCfg.ClusterName, + e.Name, + e.Container, + e.Namespace, + e.Reason, + events, + logs, + ) + + str, _ := json.Marshal(payload) + return str +} diff --git a/alertmanager/zenduty/zenduty_test.go b/alertmanager/zenduty/zenduty_test.go new file mode 100644 index 00000000..9ba1b57b --- /dev/null +++ b/alertmanager/zenduty/zenduty_test.go @@ -0,0 +1,131 @@ +package zenduty + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/abahmed/kwatch/config" + "github.com/abahmed/kwatch/event" + "github.com/stretchr/testify/assert" +) + +func TestZendutyEmptyConfig(t *testing.T) { + assert := assert.New(t) + + c := NewZenduty(map[string]interface{}{}, &config.App{ClusterName: "dev"}) + assert.Nil(c) +} + +func TestZenduty(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "integrationKey": "testtest", + } + c := NewZenduty(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(c) + + assert.Equal(c.Name(), "Zenduty") +} + +func TestSendMessage(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "integrationKey": "test", + } + c := NewZenduty(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(c) + + assert.Nil(c.SendMessage("test")) +} + +func TestSendEvent(t *testing.T) { + assert := assert.New(t) + + s := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"isOk": true}`)) + })) + + defer s.Close() + + configMap := map[string]interface{}{ + "integrationKey": "test", + } + c := NewZenduty(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(c) + + c.url = s.URL + + ev := event.Event{ + Name: "test-pod", + Container: "test-container", + Namespace: "default", + Reason: "OOMKILLED", + Logs: "test\ntestlogs", + Events: "event1-event2-event3-event1-event2-event3-event1-event2-" + + "event3\nevent5\nevent6-event8-event11-event12", + } + assert.Nil(c.SendEvent(&ev)) +} + +func TestSendEventError(t *testing.T) { + assert := assert.New(t) + + s := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + + defer s.Close() + + configMap := map[string]interface{}{ + "integrationKey": "test", + } + c := NewZenduty(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(c) + + c.url = s.URL + + ev := event.Event{ + Name: "test-pod", + Container: "test-container", + Namespace: "default", + Reason: "OOMKILLED", + Logs: "test\ntestlogs", + Events: "event1-event2-event3-event1-event2-event3-event1-event2-" + + "event3\nevent5\nevent6-event8-event11-event12", + } + assert.NotNil(c.SendEvent(&ev)) +} + +func TestInvaildHttpRequest(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "integrationKey": "test", + } + c := NewZenduty(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(c) + c.url = "h ttp://localhost" + + ev := event.Event{ + Name: "test-pod", + Container: "test-container", + Namespace: "default", + Reason: "OOMKILLED", + Logs: "test\ntestlogs", + Events: "event1-event2-event3-event1-event2-event3-event1-event2-" + + "event3\nevent5\nevent6-event8-event11-event12", + } + assert.NotNil(c.SendEvent(&ev)) + + c = NewZenduty(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(c) + c.url = "http://localhost:132323" + + assert.NotNil(c.SendEvent(&ev)) +} diff --git a/assets/zenduty.png b/assets/zenduty.png new file mode 100644 index 00000000..5d4b6e4e Binary files /dev/null and b/assets/zenduty.png differ