From b141c7c687fd0578daefaa2bfca1639cdd225503 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ahmed Date: Sun, 29 Mar 2026 00:31:06 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8E=89=20release=20v0.10.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 +++++----- deploy/chart/Chart.yaml | 4 ++-- deploy/chart/README.md | 2 +- deploy/deploy.yaml | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5e4c9a69..d74ea2ef 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ```shell helm repo add kwatch https://kwatch.dev/charts -helm install [RELEASE_NAME] kwatch/kwatch --namespace kwatch --create-namespace --version 0.10.4 +helm install [RELEASE_NAME] kwatch/kwatch --namespace kwatch --create-namespace --version 0.10.5 ``` To get more details, please check [chart's configuration](https://github.com/abahmed/kwatch/blob/main/deploy/chart/README.md) @@ -46,7 +46,7 @@ To get more details, please check [chart's configuration](https://github.com/aba You need to get config template to add your configs ```shell -curl -L https://raw.githubusercontent.com/abahmed/kwatch/v0.10.4/deploy/config.yaml -o config.yaml +curl -L https://raw.githubusercontent.com/abahmed/kwatch/v0.10.5/deploy/config.yaml -o config.yaml ``` Then edit `config.yaml` file and apply your configuration @@ -58,7 +58,7 @@ kubectl apply -f config.yaml To deploy **kwatch**, execute following command: ```shell -kubectl apply -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.4/deploy/deploy.yaml +kubectl apply -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.5/deploy/deploy.yaml ``` ## ⚙️ Configuration @@ -328,8 +328,8 @@ basic auth ### 🧹 Cleanup ```shell -kubectl delete -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.4/deploy/config.yaml -kubectl delete -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.4/deploy/deploy.yaml +kubectl delete -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.5/deploy/config.yaml +kubectl delete -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.5/deploy/deploy.yaml ``` ## 👍 Contribute & Support diff --git a/deploy/chart/Chart.yaml b/deploy/chart/Chart.yaml index 1efd8e00..9c3a1859 100644 --- a/deploy/chart/Chart.yaml +++ b/deploy/chart/Chart.yaml @@ -1,7 +1,7 @@ apiVersion: v2 name: kwatch -version: "0.10.4" -appVersion: "v0.10.4" +version: "0.10.5" +appVersion: "v0.10.5" description: monitor all changes in your Kubernetes(K8s) cluster, detects crashes in your running apps in realtime, and publishes notifications to your channels (Slack, Discord, etc.) instantly diff --git a/deploy/chart/README.md b/deploy/chart/README.md index d3c2b72f..c2076592 100644 --- a/deploy/chart/README.md +++ b/deploy/chart/README.md @@ -13,7 +13,7 @@ helm repo update ## Install Chart ```console -helm install [RELEASE_NAME] kwatch/kwatch --version 0.10.4 +helm install [RELEASE_NAME] kwatch/kwatch --version 0.10.5 ``` ## Uninstall Chart diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index a0ca4bd1..10c899ec 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -52,7 +52,7 @@ spec: serviceAccountName: kwatch containers: - name: kwatch - image: ghcr.io/abahmed/kwatch:v0.10.4 + image: ghcr.io/abahmed/kwatch:v0.10.5 imagePullPolicy: Always securityContext: runAsNonRoot: true From 221889116ffb151f618748cb50084fc668c06fb2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ahmed Date: Sun, 29 Mar 2026 00:32:48 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9D=20Remove=20'(Not=20Released)'?= =?UTF-8?q?=20from=20Health=20Check=20heading=20in=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d74ea2ef..45159ac3 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ kubectl apply -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.5/deploy | `app.logFormatter` | used for setting custom formatter when app prints logs: text, json (default: text) | -### 💓 Health Check (Not Released) +### 💓 Health Check | Parameter | Description | |:------------------------------|:------------------------------------------- | From 967b568f8c94940a3ece513b7ed2f8464e053b06 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ahmed Date: Sun, 29 Mar 2026 00:40:12 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=92=AC=20Add=20Slack=20bot=20token=20?= =?UTF-8?q?support=20(alternative=20to=20webhooks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support Slack App bot tokens (xoxb-...) with channel-based posting as an alternative to incoming webhook URLs. Config: alert.slack: token: xoxb-... # bot token channel: #alerts # required with token Existing webhook config continues to work unchanged: alert.slack: webhook: https://hooks.slack.com/... Uses slack-go/slack PostMessage API for token mode, PostWebhook for webhook mode. Both produce identical message block formatting. --- README.md | 13 +- alertmanager/slack/slack.go | 65 ++++++++-- alertmanager/slack/slack_test.go | 203 ++++++++++++++++++++++++------- 3 files changed, 223 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 45159ac3..68113918 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ kubectl apply -f https://raw.githubusercontent.com/abahmed/kwatch/v0.10.5/deploy

-If you want to enable Slack, provide the webhook with optional text and title +If you want to enable Slack, provide either a webhook URL or a bot token with channel + +**Webhook mode:** | Parameter | Description | |:---------------------------------|:------------------------------------------- | @@ -138,6 +140,15 @@ If you want to enable Slack, provide the webhook with optional text and title | `alert.slack.title` | Customized title in slack message | | `alert.slack.text` | Customized text in slack message | +**Bot Token mode:** + +| Parameter | Description | +|:---------------------------------|:------------------------------------------- | +| `alert.slack.token` | Slack bot token (xoxb-...) | +| `alert.slack.channel` | Channel to post to (e.g. #alerts) | +| `alert.slack.title` | Customized title in slack message | +| `alert.slack.text` | Customized text in slack message | + #### Discord

diff --git a/alertmanager/slack/slack.go b/alertmanager/slack/slack.go index 4cefc1d5..06694706 100644 --- a/alertmanager/slack/slack.go +++ b/alertmanager/slack/slack.go @@ -1,6 +1,7 @@ package slack import ( + "context" "fmt" "strings" @@ -17,41 +18,60 @@ const ( ) type Slack struct { - webhook string title string text string - - // used by legacy webhook to send messages to specific channel, - // instead of default one channel string + appCfg *config.App - // reference for general app configuration - appCfg *config.App + // webhook mode + webhook string + send func(url string, msg *slackClient.WebhookMessage) error - send func(url string, msg *slackClient.WebhookMessage) error + // token mode + token string + apiClient *slackClient.Client } // NewSlack returns new Slack instance func NewSlack(config map[string]interface{}, appCfg *config.App) *Slack { + title, _ := config["title"].(string) + text, _ := config["text"].(string) + + // token mode: requires token + channel + token, hasToken := config["token"].(string) + channel, hasChannel := config["channel"].(string) + if hasToken && len(token) > 0 { + if !hasChannel || len(channel) == 0 { + logrus.Warnf("initializing slack with token but missing channel") + return nil + } + logrus.Infof("initializing slack with token and channel: %s", channel) + return &Slack{ + token: token, + channel: channel, + title: title, + text: text, + appCfg: appCfg, + apiClient: slackClient.New(token), + } + } + + // webhook mode: requires webhook webhook, ok := config["webhook"].(string) if !ok || len(webhook) == 0 { - logrus.Warnf("initializing slack with empty webhook url") + logrus.Warnf("initializing slack with empty webhook url and no token") return nil } logrus.Infof("initializing slack with webhook url: %s", webhook) - channel, _ := config["channel"].(string) - title, _ := config["title"].(string) - text, _ := config["text"].(string) - return &Slack{ webhook: webhook, channel: channel, title: title, text: text, - send: slackClient.PostWebhook, appCfg: appCfg, + send: slackClient.PostWebhook, } } @@ -132,12 +152,31 @@ func (s *Slack) SendMessage(msg string) error { } func (s *Slack) sendAPI(msg *slackClient.WebhookMessage) error { + if s.apiClient != nil { + return s.sendAPIWithToken(msg) + } if len(s.channel) > 0 { msg.Channel = s.channel } return s.send(s.webhook, msg) } +func (s *Slack) sendAPIWithToken(msg *slackClient.WebhookMessage) error { + opts := []slackClient.MsgOption{} + if len(msg.Text) > 0 { + opts = append(opts, slackClient.MsgOptionText(msg.Text, false)) + } + if msg.Blocks != nil { + opts = append(opts, slackClient.MsgOptionBlocks(msg.Blocks.BlockSet...)) + } + _, _, err := s.apiClient.PostMessageContext( + context.Background(), + s.channel, + opts..., + ) + return err +} + func chunks(s string, chunkSize int) []string { if chunkSize >= len(s) { return []string{s} diff --git a/alertmanager/slack/slack_test.go b/alertmanager/slack/slack_test.go index 895283eb..929d0d61 100644 --- a/alertmanager/slack/slack_test.go +++ b/alertmanager/slack/slack_test.go @@ -12,6 +12,9 @@ import ( func mockedSend(url string, msg *slackClient.WebhookMessage) error { return nil } + +// --- webhook mode tests --- + func TestSlackEmptyConfig(t *testing.T) { assert := assert.New(t) @@ -19,7 +22,7 @@ func TestSlackEmptyConfig(t *testing.T) { assert.Nil(s) } -func TestSlack(t *testing.T) { +func TestSlackWebhook(t *testing.T) { assert := assert.New(t) configMap := map[string]interface{}{ @@ -27,11 +30,22 @@ func TestSlack(t *testing.T) { } s := NewSlack(configMap, &config.App{ClusterName: "dev"}) assert.NotNil(s) + assert.Equal("Slack", s.Name()) +} - assert.Equal(s.Name(), "Slack") +func TestSlackWebhookWithChannel(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "webhook": "testtest", + "channel": "#alerts", + } + s := NewSlack(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(s) + assert.Equal("#alerts", s.channel) } -func TestSendMessage(t *testing.T) { +func TestSendMessageWebhook(t *testing.T) { assert := assert.New(t) s := NewSlack(map[string]interface{}{ @@ -44,7 +58,7 @@ func TestSendMessage(t *testing.T) { assert.Nil(s.SendMessage("test")) } -func TestSendEvent(t *testing.T) { +func TestSendEventWebhook(t *testing.T) { assert := assert.New(t) s := NewSlack(map[string]interface{}{ @@ -54,50 +68,151 @@ func TestSendEvent(t *testing.T) { s.send = mockedSend - ev := event.Event{ + ev := &event.Event{ NodeName: "test-node", PodName: "test-pod", ContainerName: "test-container", Namespace: "default", Reason: "OOMKILLED", - Logs: "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n" + - "Nam quis nulla. Integer malesuada. In in enim a arcu " + - "imperdiet malesuada. Sed vel lectus. Donec odio urna, tempus " + - "molestie, porttitor ut, iaculis quis, sem. Phasellus rhoncus.\n", - Events: "BackOff Back-off restarting failed container\n" + - "event3\nevent5\nevent6-event8-event11-event12", + Logs: "some log line 1\nsome log line 2\nsome log line 3", + Events: "BackOff Back-off restarting failed container\nevent3\nevent5", + } + assert.Nil(s.SendEvent(ev)) +} + +func TestSendEventWebhookWithLargeLogs(t *testing.T) { + assert := assert.New(t) + + s := NewSlack(map[string]interface{}{ + "webhook": "testtest", + }, &config.App{ClusterName: "dev"}) + assert.NotNil(s) + + s.send = mockedSend + + // generate logs larger than chunkSize (2000) + longLog := "" + for i := 0; i < 500; i++ { + longLog += "Nam quis nulla. Integer malesuada. In in enim a arcu imperdiet.\n" } - assert.Nil(s.SendEvent(&ev)) + + ev := &event.Event{ + NodeName: "test-node", + PodName: "test-pod", + ContainerName: "test-container", + Namespace: "default", + Reason: "OOMKILLED", + Logs: longLog, + } + assert.Nil(s.SendEvent(ev)) +} + +// --- token mode tests --- + +func TestSlackTokenMode(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "token": "xoxb-test-token", + "channel": "#alerts", + } + s := NewSlack(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(s) + assert.Equal("Slack", s.Name()) + assert.Equal("#alerts", s.channel) + assert.NotNil(s.apiClient) + assert.Empty(s.webhook) +} + +func TestSlackTokenMissingChannel(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "token": "xoxb-test-token", + } + s := NewSlack(configMap, &config.App{ClusterName: "dev"}) + assert.Nil(s) +} + +func TestSlackTokenEmptyChannel(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "token": "xoxb-test-token", + "channel": "", + } + s := NewSlack(configMap, &config.App{ClusterName: "dev"}) + assert.Nil(s) +} + +func TestSlackWebhookPreferWebhookOverToken(t *testing.T) { + assert := assert.New(t) + + configMap := map[string]interface{}{ + "webhook": "https://hooks.slack.com/test", + "token": "", + "channel": "#alerts", + } + s := NewSlack(configMap, &config.App{ClusterName: "dev"}) + assert.NotNil(s) + // Empty token should fall through to webhook mode + assert.Equal("https://hooks.slack.com/test", s.webhook) + assert.Nil(s.apiClient) +} + +func TestSendMessageTokenMode(t *testing.T) { + assert := assert.New(t) + + s := NewSlack(map[string]interface{}{ + "token": "xoxb-test-token", + "channel": "#alerts", + }, &config.App{ClusterName: "dev"}) + assert.NotNil(s) + + // sendAPIWithToken will fail because the token is fake, + // but we're testing the dispatch path + err := s.SendMessage("test message") + assert.Error(err) // fake token, API call fails +} + +func TestSendMessageWebhookMode(t *testing.T) { + assert := assert.New(t) + + s := NewSlack(map[string]interface{}{ + "webhook": "testtest", + }, &config.App{ClusterName: "dev"}) + assert.NotNil(s) + + s.send = mockedSend + assert.Nil(s.SendMessage("test message")) +} + +// --- helper tests --- + +func TestChunks(t *testing.T) { + assert := assert.New(t) + + result := chunks("abc", 5) + assert.Equal([]string{"abc"}, result) + + result = chunks("abcdef", 3) + assert.Equal([]string{"abc", "def"}, result) + + result = chunks("abcdefg", 3) + assert.Equal([]string{"abc", "def", "g"}, result) +} + +func TestMarkdownSection(t *testing.T) { + block := markdownSection("test") + assert.Equal(t, slackClient.MBTSection, block.Type) +} + +func TestPlainSection(t *testing.T) { + block := plainSection("test") + assert.Equal(t, slackClient.MBTSection, block.Type) +} + +func TestMarkdownF(t *testing.T) { + obj := markdownF("*%s*", "test") + assert.NotNil(t, obj) }