From 9a5fbf190da636d00e9ebfba12634b7d944a1e9e Mon Sep 17 00:00:00 2001 From: TheiLLeniumStudios <104288623+TheiLLeniumStudios@users.noreply.github.com> Date: Sun, 28 Dec 2025 11:31:04 +0100 Subject: [PATCH] feat: Improve slack alerts --- internal/pkg/alerting/alerter.go | 2 +- internal/pkg/alerting/alerter_test.go | 68 +++++++++++++++++------- internal/pkg/alerting/http.go | 12 ++++- internal/pkg/alerting/raw.go | 39 ++++++++++++-- internal/pkg/alerting/slack.go | 75 ++++++++++++++++++++++++++- internal/pkg/config/config.go | 1 + 6 files changed, 172 insertions(+), 25 deletions(-) diff --git a/internal/pkg/alerting/alerter.go b/internal/pkg/alerting/alerter.go index 5213d382..edbc2281 100644 --- a/internal/pkg/alerting/alerter.go +++ b/internal/pkg/alerting/alerter.go @@ -39,7 +39,7 @@ func NewAlerter(cfg *config.Config) Alerter { case "gchat": return NewGChatAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional) default: - return NewRawAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional) + return NewRawAlerter(alertCfg.WebhookURL, alertCfg.Proxy, alertCfg.Additional, alertCfg.Structured) } } diff --git a/internal/pkg/alerting/alerter_test.go b/internal/pkg/alerting/alerter_test.go index c7dd2e8d..d6ae4ad4 100644 --- a/internal/pkg/alerting/alerter_test.go +++ b/internal/pkg/alerting/alerter_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -14,15 +15,15 @@ import ( // testServer creates a test HTTP server that captures the request body. // Returns the server and a function to retrieve the captured body. -func testServer(t *testing.T) (*httptest.Server, func() []byte) { +func testServer(t *testing.T, expectedContentType string) (*httptest.Server, func() []byte) { t.Helper() var body []byte server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("Expected POST request, got %s", r.Method) } - if r.Header.Get("Content-Type") != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + if r.Header.Get("Content-Type") != expectedContentType { + t.Errorf("Expected Content-Type %s, got %s", expectedContentType, r.Header.Get("Content-Type")) } body, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusOK) @@ -150,26 +151,38 @@ func TestNoOpAlerter_Send(t *testing.T) { func TestAlerter_Send(t *testing.T) { tests := []struct { - name string - newAlert func(url string) Alerter - validate func(t *testing.T, body []byte) + name string + contentType string + newAlert func(url string) Alerter + validate func(t *testing.T, body []byte) }{ { - name: "slack", - newAlert: func(url string) Alerter { return NewSlackAlerter(url, "", "Test Cluster") }, + name: "slack", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewSlackAlerter(url, "", "Test Cluster") }, validate: func(t *testing.T, body []byte) { var msg slackMessage if err := json.Unmarshal(body, &msg); err != nil { t.Fatalf("Failed to unmarshal: %v", err) } - if msg.Text == "" { - t.Error("Expected non-empty text") + if len(msg.Attachments) != 1 { + t.Fatalf("Expected 1 attachment, got %d", len(msg.Attachments)) + } + if msg.Attachments[0].Text == "" { + t.Error("Expected non-empty attachment text") + } + if msg.Attachments[0].Color != "good" { + t.Errorf("Expected color 'good', got %s", msg.Attachments[0].Color) + } + if msg.Attachments[0].AuthorName != "Reloader" { + t.Errorf("Expected author_name 'Reloader', got %s", msg.Attachments[0].AuthorName) } }, }, { - name: "teams", - newAlert: func(url string) Alerter { return NewTeamsAlerter(url, "", "") }, + name: "teams", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewTeamsAlerter(url, "", "") }, validate: func(t *testing.T, body []byte) { var msg teamsMessage if err := json.Unmarshal(body, &msg); err != nil { @@ -181,8 +194,9 @@ func TestAlerter_Send(t *testing.T) { }, }, { - name: "gchat", - newAlert: func(url string) Alerter { return NewGChatAlerter(url, "", "") }, + name: "gchat", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewGChatAlerter(url, "", "") }, validate: func(t *testing.T, body []byte) { var msg gchatMessage if err := json.Unmarshal(body, &msg); err != nil { @@ -194,8 +208,26 @@ func TestAlerter_Send(t *testing.T) { }, }, { - name: "raw", - newAlert: func(url string) Alerter { return NewRawAlerter(url, "", "custom-info") }, + name: "raw plain text (default)", + contentType: "text/plain", + newAlert: func(url string) Alerter { return NewRawAlerter(url, "", "custom-info", false) }, + validate: func(t *testing.T, body []byte) { + text := string(body) + if text == "" { + t.Error("Expected non-empty text") + } + if !strings.Contains(text, "custom-info") { + t.Error("Expected text to contain 'custom-info'") + } + if !strings.Contains(text, "nginx") { + t.Error("Expected text to contain workload name 'nginx'") + } + }, + }, + { + name: "raw structured JSON", + contentType: "application/json", + newAlert: func(url string) Alerter { return NewRawAlerter(url, "", "custom-info", true) }, validate: func(t *testing.T, body []byte) { var msg rawMessage if err := json.Unmarshal(body, &msg); err != nil { @@ -216,7 +248,7 @@ func TestAlerter_Send(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - server, getBody := testServer(t) + server, getBody := testServer(t, tt.contentType) defer server.Close() alerter := tt.newAlert(server.URL) @@ -234,7 +266,7 @@ func TestAlerter_WebhookError(t *testing.T) { })) defer server.Close() - alerter := NewRawAlerter(server.URL, "", "") + alerter := NewRawAlerter(server.URL, "", "", false) if err := alerter.Send(context.Background(), AlertMessage{}); err == nil { t.Error("Expected error for non-2xx response") } diff --git a/internal/pkg/alerting/http.go b/internal/pkg/alerting/http.go index ab086e57..e5bb3890 100644 --- a/internal/pkg/alerting/http.go +++ b/internal/pkg/alerting/http.go @@ -36,11 +36,21 @@ func newHTTPClient(proxyURL string) *httpClient { // post sends a POST request with JSON body. func (c *httpClient) post(ctx context.Context, url string, body []byte) error { + return c.doPost(ctx, url, body, "application/json") +} + +// postText sends a POST request with plain text body. +func (c *httpClient) postText(ctx context.Context, url string, text string) error { + return c.doPost(ctx, url, []byte(text), "text/plain") +} + +// doPost sends a POST request with the specified content type. +func (c *httpClient) doPost(ctx context.Context, url string, body []byte, contentType string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("creating request: %w", err) } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) resp, err := c.client.Do(req) if err != nil { diff --git a/internal/pkg/alerting/raw.go b/internal/pkg/alerting/raw.go index ad0add08..d8ea3046 100644 --- a/internal/pkg/alerting/raw.go +++ b/internal/pkg/alerting/raw.go @@ -4,25 +4,29 @@ import ( "context" "encoding/json" "fmt" + "strings" ) -// RawAlerter sends alerts as raw JSON to a webhook. +// RawAlerter sends alerts to a webhook as plain text (default) or structured JSON. type RawAlerter struct { webhookURL string additional string + structured bool client *httpClient } // NewRawAlerter creates a new RawAlerter. -func NewRawAlerter(webhookURL, proxyURL, additional string) *RawAlerter { +// If structured is true, sends JSON; otherwise sends plain text. +func NewRawAlerter(webhookURL, proxyURL, additional string, structured bool) *RawAlerter { return &RawAlerter{ webhookURL: webhookURL, additional: additional, + structured: structured, client: newHTTPClient(proxyURL), } } -// rawMessage is the JSON payload for raw webhook alerts. +// rawMessage is the JSON payload for structured raw webhook alerts. type rawMessage struct { Event string `json:"event"` WorkloadKind string `json:"workloadKind"` @@ -36,6 +40,13 @@ type rawMessage struct { } func (a *RawAlerter) Send(ctx context.Context, message AlertMessage) error { + if a.structured { + return a.sendStructured(ctx, message) + } + return a.sendPlainText(ctx, message) +} + +func (a *RawAlerter) sendStructured(ctx context.Context, message AlertMessage) error { msg := rawMessage{ Event: "reload", WorkloadKind: message.WorkloadKind, @@ -55,3 +66,25 @@ func (a *RawAlerter) Send(ctx context.Context, message AlertMessage) error { return a.client.post(ctx, a.webhookURL, body) } + +func (a *RawAlerter) sendPlainText(ctx context.Context, message AlertMessage) error { + text := a.formatMessage(message) + // Strip markdown formatting for plain text + text = strings.ReplaceAll(text, "*", "") + return a.client.postText(ctx, a.webhookURL, text) +} + +func (a *RawAlerter) formatMessage(msg AlertMessage) string { + text := fmt.Sprintf( + "Reloader triggered reload - Workload: %s/%s (%s), Resource: %s/%s (%s), Time: %s", + msg.WorkloadNamespace, msg.WorkloadName, msg.WorkloadKind, + msg.ResourceNamespace, msg.ResourceName, msg.ResourceKind, + msg.Timestamp.Format("2006-01-02 15:04:05 UTC"), + ) + + if a.additional != "" { + text = a.additional + " : " + text + } + + return text +} diff --git a/internal/pkg/alerting/slack.go b/internal/pkg/alerting/slack.go index 1b917118..68df2ac0 100644 --- a/internal/pkg/alerting/slack.go +++ b/internal/pkg/alerting/slack.go @@ -22,13 +22,84 @@ func NewSlackAlerter(webhookURL, proxyURL, additional string) *SlackAlerter { } } +// slackMessage represents a Slack webhook message. type slackMessage struct { - Text string `json:"text"` + Username string `json:"username,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` + Channel string `json:"channel,omitempty"` + ThreadTimestamp string `json:"thread_ts,omitempty"` + Text string `json:"text,omitempty"` + Attachments []slackAttachment `json:"attachments,omitempty"` + Parse string `json:"parse,omitempty"` + ResponseType string `json:"response_type,omitempty"` + ReplaceOriginal bool `json:"replace_original,omitempty"` + DeleteOriginal bool `json:"delete_original,omitempty"` + ReplyBroadcast bool `json:"reply_broadcast,omitempty"` +} + +// slackAttachment represents a Slack message attachment. +type slackAttachment struct { + Color string `json:"color,omitempty"` + Fallback string `json:"fallback,omitempty"` + + CallbackID string `json:"callback_id,omitempty"` + ID int `json:"id,omitempty"` + + AuthorID string `json:"author_id,omitempty"` + AuthorName string `json:"author_name,omitempty"` + AuthorSubname string `json:"author_subname,omitempty"` + AuthorLink string `json:"author_link,omitempty"` + AuthorIcon string `json:"author_icon,omitempty"` + + Title string `json:"title,omitempty"` + TitleLink string `json:"title_link,omitempty"` + Pretext string `json:"pretext,omitempty"` + Text string `json:"text,omitempty"` + + ImageURL string `json:"image_url,omitempty"` + ThumbURL string `json:"thumb_url,omitempty"` + + ServiceName string `json:"service_name,omitempty"` + ServiceIcon string `json:"service_icon,omitempty"` + FromURL string `json:"from_url,omitempty"` + OriginalURL string `json:"original_url,omitempty"` + + Fields []slackField `json:"fields,omitempty"` + MarkdownIn []string `json:"mrkdwn_in,omitempty"` + + Footer string `json:"footer,omitempty"` + FooterIcon string `json:"footer_icon,omitempty"` + + Actions []slackAction `json:"actions,omitempty"` +} + +// slackField represents a field in a Slack attachment. +type slackField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short"` +} + +// slackAction represents an action button in a Slack attachment. +type slackAction struct { + Type string `json:"type"` + Text string `json:"text"` + URL string `json:"url"` + Style string `json:"style"` } func (a *SlackAlerter) Send(ctx context.Context, message AlertMessage) error { text := a.formatMessage(message) - msg := slackMessage{Text: text} + msg := slackMessage{ + Attachments: []slackAttachment{ + { + Text: text, + Color: "good", + AuthorName: "Reloader", + }, + }, + } body, err := json.Marshal(msg) if err != nil { diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 0bb972d9..583b4dca 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -84,6 +84,7 @@ type AlertingConfig struct { Sink string `json:"sink,omitempty"` Proxy string `json:"proxy,omitempty"` Additional string `json:"additional,omitempty"` + Structured bool `json:"structured,omitempty"` // For raw sink: send structured JSON instead of plain text } // LeaderElectionConfig holds configuration for leader election.