Skip to content
Open
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: 10 additions & 2 deletions api/v1alpha1/endpointmonitor_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ type EndpointMonitorSpec struct {

// NotifyConfig holds notifier configurations
type NotifyConfig struct {
Slack *SlackConfig `json:"slack,omitempty"`
Email *EmailConfig `json:"email,omitempty"`
Slack *SlackConfig `json:"slack,omitempty"`
Email *EmailConfig `json:"email,omitempty"`
Discord *DiscordConfig `json:"discord,omitempty"`
}

// SlackConfig defines Slack notifier config
Expand All @@ -45,6 +46,13 @@ type SecretRef struct {
Name string `json:"name"`
}

// DiscordConfig defines Discord notifier config
type DiscordConfig struct {
Enabled bool `json:"enabled"`
WebhookURL string `json:"webhookUrl"`
AlertOn []string `json:"alertOn,omitempty"` // values: "success", "failure"
}

// EndpointMonitorStatus defines the observed state of EndpointMonitor
type EndpointMonitorStatus struct {
LastCheckedTime metav1.Time `json:"lastCheckedTime,omitempty"`
Expand Down
25 changes: 25 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions config/crd/bases/monitoring.licious.app_endpointmonitors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ spec:
notify:
description: NotifyConfig holds notifier configurations
properties:
discord:
description: DiscordConfig defines Discord notifier config
properties:
alertOn:
items:
type: string
type: array
enabled:
type: boolean
webhookUrl:
type: string
required:
- enabled
- webhookUrl
type: object
email:
description: EmailConfig is placeholder (no-op for now)
properties:
Expand Down
15 changes: 15 additions & 0 deletions dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,21 @@ spec:
notify:
description: NotifyConfig holds notifier configurations
properties:
discord:
description: DiscordConfig defines Discord notifier config
properties:
alertOn:
items:
type: string
type: array
enabled:
type: boolean
webhookUrl:
type: string
required:
- enabled
- webhookUrl
type: object
email:
description: EmailConfig is placeholder (no-op for now)
properties:
Expand Down
22 changes: 22 additions & 0 deletions examples/discord.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
apiVersion: monitoring.licious.app/v1alpha1
kind: EndpointMonitor
metadata:
name: discord-ping-example
namespace: endpoint-monitoring-operator-system
spec:
driver: ping
endpoint: a.b.c.d
checkInterval: 60
notify:
discord:
enabled: true
webhookUrl: https://discord.com/api/webhooks/XXX/YYY
alertOn:
- failure
- success
slack:
enabled: true
webhookUrl: https://hooks.slack.com/services/XXX/YYY/ZZZ
alertOn:
- failure
- success
69 changes: 69 additions & 0 deletions internal/notifier/discord/discord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package discord

import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"

"github.com/LiciousTech/endpoint-monitoring-operator/api/v1alpha1"
"github.com/LiciousTech/endpoint-monitoring-operator/internal/notifier"
)

type DiscordNotifier struct {
cfg *v1alpha1.DiscordConfig
}

func New(config *v1alpha1.DiscordConfig) (notifier.Notifier, error) {
if config == nil || !config.Enabled || config.WebhookURL == "" {
return nil, fmt.Errorf("invalid Discord config")
}
return &DiscordNotifier{cfg: config}, nil
}

func (d *DiscordNotifier) SendAlert(status string, msg string) error {
if !d.shouldAlert(status) {
return nil // silently skip
}

styledMsg := d.formatDiscordMessage(status, msg)
payload := map[string]string{"content": styledMsg}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal discord payload: %w", err)
}

resp, err := http.Post(d.cfg.WebhookURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send discord alert: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("non-2xx response from discord: %s", resp.Status)
}

return nil
}

func (d *DiscordNotifier) shouldAlert(status string) bool {
return notifier.ShouldAlert(d.cfg.AlertOn, status)
}

func (d *DiscordNotifier) formatDiscordMessage(status, msg string) string {
var statusEmoji string
switch status {
case "success":
statusEmoji = ":white_check_mark:"
case "failure":
statusEmoji = ":x:"
default:
statusEmoji = ":information_source:"
}

return fmt.Sprintf(
"%s **Endpoint Monitor Alert** %s\n\n**Status:** %s\n\n**Details:**\n```\n%s\n```",
statusEmoji, statusEmoji, strings.ToUpper(status), msg,
)
}
97 changes: 97 additions & 0 deletions internal/notifier/discord/discord_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package discord

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/LiciousTech/endpoint-monitoring-operator/api/v1alpha1"
)

type alertPayload struct {
Content string `json:"content"`
}

func TestDiscordNotifier_SendAlert(t *testing.T) {
var receivedPayload alertPayload
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(&receivedPayload); err != nil {
t.Errorf("failed to decode payload: %v", err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()

n := &DiscordNotifier{cfg: &v1alpha1.DiscordConfig{
Enabled: true,
WebhookURL: ts.URL,
AlertOn: []string{"failure"},
}}

t.Run("should send alert on failure", func(t *testing.T) {
err := n.SendAlert("failure", "test message")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
// Update: Expect the styled message format
expected := n.formatDiscordMessage("failure", "test message")
if receivedPayload.Content != expected {
t.Errorf("expected message '%s', got '%s'", expected, receivedPayload.Content)
}
})

t.Run("should not send alert on success if not configured", func(t *testing.T) {
err := n.SendAlert("success", "should not send")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if receivedPayload.Content == "should not send" {
t.Errorf("should not have sent alert for success")
}
})
}

func TestDiscordNotifier_shouldAlert(t *testing.T) {
n := &DiscordNotifier{cfg: &v1alpha1.DiscordConfig{AlertOn: []string{"failure"}}}
if !n.shouldAlert("failure") {
t.Error("should alert on failure")
}
if n.shouldAlert("success") {
t.Error("should not alert on success")
}

n.cfg.AlertOn = nil
if !n.shouldAlert("failure") {
t.Error("should default to alert on failure")
}
if n.shouldAlert("success") {
t.Error("should not alert on success by default")
}
}

func TestDiscordNotifier_formatDiscordMessage(t *testing.T) {
n := &DiscordNotifier{cfg: &v1alpha1.DiscordConfig{}}

t.Run("failure status", func(t *testing.T) {
msg := n.formatDiscordMessage("failure", "something went wrong")
if want := ":x: **Endpoint Monitor Alert** :x:\n\n**Status:** FAILURE\n\n**Details:**\n```\nsomething went wrong\n```"; msg != want {
t.Errorf("unexpected format for failure: got %q, want %q", msg, want)
}
})

t.Run("success status", func(t *testing.T) {
msg := n.formatDiscordMessage("success", "all good")
if want := ":white_check_mark: **Endpoint Monitor Alert** :white_check_mark:\n\n**Status:** SUCCESS\n\n**Details:**\n```\nall good\n```"; msg != want {
t.Errorf("unexpected format for success: got %q, want %q", msg, want)
}
})

t.Run("other status", func(t *testing.T) {
msg := n.formatDiscordMessage("info", "misc info")
if want := ":information_source: **Endpoint Monitor Alert** :information_source:\n\n**Status:** INFO\n\n**Details:**\n```\nmisc info\n```"; msg != want {
t.Errorf("unexpected format for info: got %q, want %q", msg, want)
}
})
}
14 changes: 14 additions & 0 deletions internal/notifier/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,17 @@ package notifier
type Notifier interface {
SendAlert(status string, msg string) error
}

// ShouldAlert returns true if the given status is in the allowed list, or if the list is empty and status is "failure".
func ShouldAlert(allowed []string, status string) bool {
if len(allowed) == 0 {
// Default to alert only on failure
return status == "failure"
}
for _, a := range allowed {
if a == status {
return true
}
}
return false
}
12 changes: 1 addition & 11 deletions internal/notifier/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,5 @@ func (s *SlackNotifier) SendAlert(status string, msg string) error {
}

func (s *SlackNotifier) shouldAlert(status string) bool {
if len(s.cfg.AlertOn) == 0 {
// Default to alert only on failure
return status == "failure"
}

for _, allowed := range s.cfg.AlertOn {
if allowed == status {
return true
}
}
return false
return notifier.ShouldAlert(s.cfg.AlertOn, status)
}
9 changes: 9 additions & 0 deletions pkg/factory/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/LiciousTech/endpoint-monitoring-operator/internal/notifier"
"github.com/LiciousTech/endpoint-monitoring-operator/internal/notifier/email"
"github.com/LiciousTech/endpoint-monitoring-operator/internal/notifier/slack"
"github.com/LiciousTech/endpoint-monitoring-operator/internal/notifier/discord"
)

// NotifierFactory creates notifiers based on configuration
Expand Down Expand Up @@ -43,6 +44,14 @@ func (f *NotifierFactory) CreateNotifier(config *v1alpha1.NotifyConfig) (notifie
notifiers = append(notifiers, emailNotifier)
}

if config.Discord != nil && config.Discord.Enabled {
discordNotifier, err := discord.New(config.Discord)
if err != nil {
return nil, fmt.Errorf("failed to create Discord notifier: %w", err)
}
notifiers = append(notifiers, discordNotifier)
}

if len(notifiers) == 0 {
return nil, fmt.Errorf("no notifiers enabled")
}
Expand Down