feat: Improve slack alerts

This commit is contained in:
TheiLLeniumStudios
2025-12-28 11:31:04 +01:00
parent 2eeb44dd18
commit 9a5fbf190d
6 changed files with 172 additions and 25 deletions
+1 -1
View File
@@ -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)
}
}
+50 -18
View File
@@ -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")
}
+11 -1
View File
@@ -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 {
+36 -3
View File
@@ -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
}
+73 -2
View File
@@ -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 {
+1
View File
@@ -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.