mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-20 12:36:26 +00:00
feat: Improve slack alerts
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user