diff --git a/api/v1alpha1/endpointmonitor_types.go b/api/v1alpha1/endpointmonitor_types.go index 3af0ff3..e6e15a8 100644 --- a/api/v1alpha1/endpointmonitor_types.go +++ b/api/v1alpha1/endpointmonitor_types.go @@ -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 @@ -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"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a6b5d78..fd3f06e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,26 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DiscordConfig) DeepCopyInto(out *DiscordConfig) { + *out = *in + if in.AlertOn != nil { + in, out := &in.AlertOn, &out.AlertOn + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiscordConfig. +func (in *DiscordConfig) DeepCopy() *DiscordConfig { + if in == nil { + return nil + } + out := new(DiscordConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EmailConfig) DeepCopyInto(out *EmailConfig) { *out = *in @@ -176,6 +196,11 @@ func (in *NotifyConfig) DeepCopyInto(out *NotifyConfig) { *out = new(EmailConfig) (*in).DeepCopyInto(*out) } + if in.Discord != nil { + in, out := &in.Discord, &out.Discord + *out = new(DiscordConfig) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotifyConfig. diff --git a/config/crd/bases/monitoring.licious.app_endpointmonitors.yaml b/config/crd/bases/monitoring.licious.app_endpointmonitors.yaml index c9d9dd2..b56abed 100644 --- a/config/crd/bases/monitoring.licious.app_endpointmonitors.yaml +++ b/config/crd/bases/monitoring.licious.app_endpointmonitors.yaml @@ -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: diff --git a/dist/install.yaml b/dist/install.yaml index c9d0622..5924cd9 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -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: diff --git a/examples/discord.yaml b/examples/discord.yaml new file mode 100644 index 0000000..b2fc228 --- /dev/null +++ b/examples/discord.yaml @@ -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 \ No newline at end of file diff --git a/internal/notifier/discord/discord.go b/internal/notifier/discord/discord.go new file mode 100644 index 0000000..8fa7811 --- /dev/null +++ b/internal/notifier/discord/discord.go @@ -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, + ) +} diff --git a/internal/notifier/discord/discord_test.go b/internal/notifier/discord/discord_test.go new file mode 100644 index 0000000..88b51d2 --- /dev/null +++ b/internal/notifier/discord/discord_test.go @@ -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) + } + }) +} diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go index dbaf7bf..4310add 100644 --- a/internal/notifier/notifier.go +++ b/internal/notifier/notifier.go @@ -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 +} diff --git a/internal/notifier/slack/slack.go b/internal/notifier/slack/slack.go index 6635243..32e345a 100644 --- a/internal/notifier/slack/slack.go +++ b/internal/notifier/slack/slack.go @@ -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) } diff --git a/pkg/factory/factory.go b/pkg/factory/factory.go index c6b8fbe..ee976c9 100644 --- a/pkg/factory/factory.go +++ b/pkg/factory/factory.go @@ -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 @@ -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") }