Alerting: Add support for wecom apiapp (#55991)

This change adds new functionality to the wecom alerting contact point. In addition to a webhook address, you can now send alerts to the wecom apiapp endpoint.

Based on https://github.com/grafana/grafana/discussions/55883

Signed-off-by: aimuz <mr.imuz@gmail.com>
This commit is contained in:
aimuz 2022-10-19 12:17:37 +08:00 committed by GitHub
parent b2408dd7c5
commit c0cc85b5f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 594 additions and 22 deletions

View File

@ -5,40 +5,92 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
"golang.org/x/sync/singleflight"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/notifications"
)
var weComEndpoint = "https://qyapi.weixin.qq.com"
const defaultWeComChannelType = "groupRobot"
const defaultWeComMsgType = WeComMsgTypeMarkdown
const defaultWeComToUser = "@all"
type WeComMsgType string
const WeComMsgTypeMarkdown WeComMsgType = "markdown" // use these in available_channels.go too
const WeComMsgTypeText WeComMsgType = "text"
// IsValid checks wecom message type
func (mt WeComMsgType) IsValid() bool {
return mt == WeComMsgTypeMarkdown || mt == WeComMsgTypeText
}
type wecomSettings struct {
URL string `json:"url" yaml:"url"`
Message string `json:"message,omitempty" yaml:"message,omitempty"`
Title string `json:"title,omitempty" yaml:"title,omitempty"`
channel string
EndpointURL string `json:"endpointUrl,omitempty" yaml:"endpointUrl,omitempty"`
URL string `json:"url" yaml:"url"`
AgentID string `json:"agent_id,omitempty" yaml:"agent_id,omitempty"`
CorpID string `json:"corp_id,omitempty" yaml:"corp_id,omitempty"`
Secret string `json:"secret,omitempty" yaml:"secret,omitempty"`
MsgType WeComMsgType `json:"msgtype,omitempty" yaml:"msgtype,omitempty"`
Message string `json:"message,omitempty" yaml:"message,omitempty"`
Title string `json:"title,omitempty" yaml:"title,omitempty"`
ToUser string `json:"touser,omitempty" yaml:"touser,omitempty"`
}
func buildWecomSettings(factoryConfig FactoryConfig) (wecomSettings, error) {
var settings = wecomSettings{}
var settings = wecomSettings{
channel: defaultWeComChannelType,
}
err := factoryConfig.Config.unmarshalSettings(&settings)
if err != nil {
return settings, fmt.Errorf("failed to unmarshal settings: %w", err)
}
if settings.Message == "" {
if len(settings.EndpointURL) == 0 {
settings.EndpointURL = weComEndpoint
}
if !settings.MsgType.IsValid() {
settings.MsgType = defaultWeComMsgType
}
if len(settings.Message) == 0 {
settings.Message = DefaultMessageEmbed
}
if settings.Title == "" {
if len(settings.Title) == 0 {
settings.Title = DefaultMessageTitleEmbed
}
if len(settings.ToUser) == 0 {
settings.ToUser = defaultWeComToUser
}
settings.URL = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "url", settings.URL)
if settings.URL == "" {
return settings, errors.New("could not find webhook URL in settings")
settings.Secret = factoryConfig.DecryptFunc(context.Background(), factoryConfig.Config.SecureSettings, "secret", settings.Secret)
if len(settings.URL) == 0 && len(settings.Secret) == 0 {
return settings, errors.New("either url or secret is required")
}
if len(settings.URL) == 0 {
settings.channel = "apiapp"
if len(settings.AgentID) == 0 {
return settings, errors.New("could not find AgentID in settings")
}
if len(settings.CorpID) == 0 {
return settings, errors.New("could not find CorpID in settings")
}
}
return settings, nil
}
@ -76,10 +128,13 @@ func buildWecomNotifier(factoryConfig FactoryConfig) (*WeComNotifier, error) {
// WeComNotifier is responsible for sending alert notifications to WeCom.
type WeComNotifier struct {
*Base
tmpl *template.Template
log log.Logger
ns notifications.WebhookSender
settings wecomSettings
tmpl *template.Template
log log.Logger
ns notifications.WebhookSender
settings wecomSettings
tok *WeComAccessToken
tokExpireAt time.Time
group singleflight.Group
}
// Notify send an alert notification to WeCom.
@ -90,28 +145,50 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e
tmpl, _ := TmplText(ctx, w.tmpl, as, w.log, &tmplErr)
bodyMsg := map[string]interface{}{
"msgtype": "markdown",
"msgtype": w.settings.MsgType,
}
content := fmt.Sprintf("# %s\n%s\n",
tmpl(w.settings.Title),
tmpl(w.settings.Message),
)
if w.settings.MsgType != defaultWeComMsgType {
content = fmt.Sprintf("%s\n%s\n",
tmpl(w.settings.Title),
tmpl(w.settings.Message),
)
}
bodyMsg["markdown"] = map[string]interface{}{
msgType := string(w.settings.MsgType)
bodyMsg[msgType] = map[string]interface{}{
"content": content,
}
url := w.settings.URL
if w.settings.channel != defaultWeComChannelType {
bodyMsg["agentid"] = w.settings.AgentID
bodyMsg["touser"] = w.settings.ToUser
token, err := w.GetAccessToken(ctx)
if err != nil {
return false, err
}
url = fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/message/send?access_token=%s", token)
}
body, err := json.Marshal(bodyMsg)
if err != nil {
return false, err
}
if tmplErr != nil {
w.log.Warn("failed to template WeCom message", "err", tmplErr.Error())
}
cmd := &models.SendWebhookSync{
Url: w.settings.URL,
Url: url,
Body: string(body),
}
if err := w.ns.SendWebhookSync(ctx, cmd); err != nil {
if err = w.ns.SendWebhookSync(ctx, cmd); err != nil {
w.log.Error("failed to send WeCom webhook", "err", err, "notification", w.Name)
return false, err
}
@ -119,6 +196,67 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e
return true, nil
}
// GetAccessToken returns the access token for apiapp
func (w *WeComNotifier) GetAccessToken(ctx context.Context) (string, error) {
t := w.tok
if w.tokExpireAt.Before(time.Now()) || w.tok == nil {
// avoid multiple calls when there are multiple alarms
tok, err, _ := w.group.Do("GetAccessToken", func() (interface{}, error) {
return w.getAccessToken(ctx)
})
if err != nil {
return "", err
}
t = tok.(*WeComAccessToken)
// expire five minutes in advance to avoid using it when it is about to expire
w.tokExpireAt = time.Now().Add(time.Second * time.Duration(t.ExpireIn-300))
w.tok = t
}
return t.AccessToken, nil
}
type WeComAccessToken struct {
AccessToken string `json:"access_token"`
ErrMsg string `json:"errmsg"`
ErrCode int `json:"errcode"`
ExpireIn int `json:"expire_in"`
}
func (w *WeComNotifier) getAccessToken(ctx context.Context) (*WeComAccessToken, error) {
geTokenURL := fmt.Sprintf(w.settings.EndpointURL+"/cgi-bin/gettoken?corpid=%s&corpsecret=%s", w.settings.CorpID, w.settings.Secret)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, geTokenURL, nil)
if err != nil {
return nil, err
}
request.Header.Add("Content-Type", "application/json")
request.Header.Add("User-Agent", "Grafana")
resp, err := http.DefaultClient.Do(request)
if err != nil {
return nil, err
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("WeCom returned statuscode invalid status code: %v", resp.Status)
}
defer func() {
_ = resp.Body.Close()
}()
var accessToken WeComAccessToken
err = json.NewDecoder(resp.Body).Decode(&accessToken)
if err != nil {
return nil, err
}
if accessToken.ErrCode != 0 {
return nil, fmt.Errorf("WeCom returned errmsg: %s", accessToken.ErrMsg)
}
return &accessToken, nil
}
func (w *WeComNotifier) SendResolved() bool {
return !w.GetDisableResolveMessage()
}

View File

@ -3,8 +3,13 @@ package channels
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
@ -12,6 +17,7 @@ import (
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
@ -50,7 +56,8 @@ func TestWeComNotifier(t *testing.T) {
"msgtype": "markdown",
},
expMsgError: nil,
}, {
},
{
name: "Custom config with multiple alerts",
settings: `{
"url": "http://localhost",
@ -76,7 +83,8 @@ func TestWeComNotifier(t *testing.T) {
"msgtype": "markdown",
},
expMsgError: nil,
}, {
},
{
name: "Custom title and message with multiple alerts",
settings: `{
"url": "http://localhost",
@ -103,10 +111,11 @@ func TestWeComNotifier(t *testing.T) {
"msgtype": "markdown",
},
expMsgError: nil,
}, {
},
{
name: "Error in initing",
settings: `{}`,
expInitError: `could not find webhook URL in settings`,
expInitError: `either url or secret is required`,
},
{
name: "Use default if optional fields are explicitly empty",
@ -127,6 +136,25 @@ func TestWeComNotifier(t *testing.T) {
},
expMsgError: nil,
},
{
name: "Use text are explicitly empty",
settings: `{"url": "http://localhost", "message": "", "title": "", "msgtype": "text"}`,
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
},
},
},
expMsg: map[string]interface{}{
"text": map[string]interface{}{
"content": "[FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n",
},
"msgtype": "text",
},
expMsgError: nil,
},
}
for _, c := range cases {
@ -178,3 +206,353 @@ func TestWeComNotifier(t *testing.T) {
})
}
}
// TestWeComNotifierAPIAPP Testing API Channels
func TestWeComNotifierAPIAPP(t *testing.T) {
tmpl := templateForTests(t)
externalURL, err := url.Parse("http://localhost")
require.NoError(t, err)
tmpl.ExternalURL = externalURL
tests := []struct {
name string
settings string
statusCode int
accessToken string
alerts []*types.Alert
expMsg map[string]interface{}
expInitError string
expMsgError error
}{
{
name: "not AgentID",
settings: `{"secret": "secret"}`,
accessToken: "access_token",
expInitError: "could not find AgentID in settings",
},
{
name: "not CorpID",
settings: `{"secret": "secret", "agent_id": "agent_id"}`,
accessToken: "access_token",
expInitError: "could not find CorpID in settings",
},
{
name: "Default APIAPP config with one alert",
settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`,
accessToken: "access_token",
expInitError: "",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
Annotations: model.LabelSet{"ann1": "annv1", "__dashboardUid__": "abcd", "__panelId__": "efgh"},
},
},
},
expMsg: map[string]interface{}{
"markdown": map[string]interface{}{
"content": "# [FIRING:1] (val1)\n**Firing**\n\nValue: [no value]\nLabels:\n - alertname = alert1\n - lbl1 = val1\nAnnotations:\n - ann1 = annv1\nSilence: http://localhost/alerting/silence/new?alertmanager=grafana&matcher=alertname%3Dalert1&matcher=lbl1%3Dval1\nDashboard: http://localhost/d/abcd\nPanel: http://localhost/d/abcd?viewPanel=efgh\n\n",
},
"msgtype": "markdown",
"agentid": "agent_id",
"touser": "@all",
},
},
{
name: "Custom message(markdown) with multiple alert",
settings: `{
"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id",
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"}
`,
accessToken: "access_token",
expInitError: "",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
Annotations: model.LabelSet{"ann1": "annv1"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
Annotations: model.LabelSet{"ann1": "annv2"},
},
},
},
expMsg: map[string]interface{}{
"markdown": map[string]interface{}{
"content": "# [FIRING:2] \n2 alerts are firing, 0 are resolved\n",
},
"msgtype": "markdown",
"agentid": "agent_id",
"touser": "@all",
},
expMsgError: nil,
},
{
name: "Custom message(Text) with multiple alert",
settings: `{
"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id",
"msgtype": "text",
"message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved"}
`,
accessToken: "access_token",
expInitError: "",
alerts: []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"},
Annotations: model.LabelSet{"ann1": "annv1"},
},
},
{
Alert: model.Alert{
Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"},
Annotations: model.LabelSet{"ann1": "annv2"},
},
},
},
expMsg: map[string]interface{}{
"text": map[string]interface{}{
"content": "[FIRING:2] \n2 alerts are firing, 0 are resolved\n",
},
"msgtype": "text",
"agentid": "agent_id",
"touser": "@all",
},
expMsgError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessToken := r.URL.Query().Get("access_token")
if accessToken != tt.accessToken {
t.Errorf("Expected access_token=%s got %s", tt.accessToken, accessToken)
return
}
expBody, err := json.Marshal(tt.expMsg)
require.NoError(t, err)
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.JSONEq(t, string(expBody), string(b))
}))
defer server.Close()
settingsJSON, err := simplejson.NewJson([]byte(tt.settings))
require.NoError(t, err)
m := &NotificationChannelConfig{
Name: "wecom_testing",
Type: "wecom",
Settings: settingsJSON,
}
webhookSender := mockNotificationService()
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
fc := FactoryConfig{
Config: m,
NotificationService: webhookSender,
DecryptFunc: secretsService.GetDecryptedValue,
ImageStore: nil,
Template: tmpl,
}
pn, err := buildWecomNotifier(fc)
if tt.expInitError != "" {
require.Equal(t, tt.expInitError, err.Error())
return
}
require.NoError(t, err)
ctx := notify.WithGroupKey(context.Background(), "alertname")
ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": ""})
// Avoid calling GetAccessToken interfaces
pn.tokExpireAt = time.Now().Add(10 * time.Second)
pn.tok = &WeComAccessToken{AccessToken: tt.accessToken}
ok, err := pn.Notify(ctx, tt.alerts...)
if tt.expMsgError != nil {
require.False(t, ok)
require.Error(t, err)
require.Equal(t, tt.expMsgError.Error(), err.Error())
return
}
require.NoError(t, err)
require.True(t, ok)
expBody, err := json.Marshal(tt.expMsg)
require.NoError(t, err)
require.JSONEq(t, string(expBody), webhookSender.Webhook.Body)
})
}
}
func TestWeComNotifier_GetAccessToken(t *testing.T) {
type fields struct {
tok *WeComAccessToken
tokExpireAt time.Time
corpid string
secret string
}
tests := []struct {
name string
fields fields
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no corpid",
fields: fields{
tok: nil,
tokExpireAt: time.Now().Add(-time.Minute),
},
want: "",
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.Error(t, err, i...)
},
},
{
name: "no corpsecret",
fields: fields{
tok: nil,
tokExpireAt: time.Now().Add(-time.Minute),
},
want: "",
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.Error(t, err, i...)
},
},
{
name: "get access token",
fields: fields{
corpid: "corpid",
secret: "secret",
},
want: "access_token",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
corpid := r.URL.Query().Get("corpid")
corpsecret := r.URL.Query().Get("corpsecret")
assert.Equal(t, corpid, tt.fields.corpid, fmt.Sprintf("Expected corpid=%s got %s", tt.fields.corpid, corpid))
if len(corpid) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
assert.Equal(t, corpsecret, tt.fields.secret, fmt.Sprintf("Expected corpsecret=%s got %s", tt.fields.secret, corpsecret))
if len(corpsecret) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
b, err := json.Marshal(map[string]interface{}{
"errcode": 0,
"errmsg": "ok",
"access_token": tt.want,
"expires_in": 7200,
})
assert.NoError(t, err)
w.WriteHeader(http.StatusOK)
_, err = w.Write(b)
assert.NoError(t, err)
}))
defer server.Close()
w := &WeComNotifier{
settings: wecomSettings{
EndpointURL: server.URL,
CorpID: tt.fields.corpid,
Secret: tt.fields.secret,
},
tok: tt.fields.tok,
tokExpireAt: tt.fields.tokExpireAt,
}
got, err := w.GetAccessToken(context.Background())
if !tt.wantErr(t, err, "GetAccessToken()") {
return
}
assert.Equalf(t, tt.want, got, "GetAccessToken()")
})
}
}
func TestWeComFactory(t *testing.T) {
tests := []struct {
name string
settings string
wantErr assert.ErrorAssertionFunc
}{
{
name: "null",
settings: "{}",
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.Contains(t, err.Error(), "either url or secret is required", i...)
},
},
{
name: "webhook url",
settings: `{"url": "https://example.com"}`,
wantErr: assert.NoError,
},
{
name: "apiapp missing AgentID",
settings: `{"secret": "secret"}`,
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.Contains(t, err.Error(), "could not find AgentID in settings", i...)
},
},
{
name: "apiapp missing CorpID",
settings: `{"secret": "secret", "agent_id": "agent_id"}`,
wantErr: func(t assert.TestingT, err error, i ...interface{}) bool {
return assert.Contains(t, err.Error(), "could not find CorpID in settings", i...)
},
},
{
name: "apiapp",
settings: `{"secret": "secret", "agent_id": "agent_id", "corp_id": "corp_id"}`,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settingsJSON, err := simplejson.NewJson([]byte(tt.settings))
require.NoError(t, err)
m := &NotificationChannelConfig{
Name: "wecom_testing",
Type: "wecom",
Settings: settingsJSON,
}
webhookSender := mockNotificationService()
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
fc := FactoryConfig{
Config: m,
NotificationService: webhookSender,
DecryptFunc: secretsService.GetDecryptedValue,
ImageStore: nil,
}
_, err = WeComFactory(fc)
if !tt.wantErr(t, err, fmt.Sprintf("WeComFactory(%v)", fc)) {
return
}
})
}
}

View File

@ -696,13 +696,62 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Heading: "WeCom settings",
Options: []NotifierOption{
{
Label: "URL",
Label: "Webhook URL",
Description: "Required if using GroupRobot",
Element: ElementTypeInput,
InputType: InputTypeText,
Placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx",
PropertyName: "url",
Required: true,
Secure: true,
Required: true,
DependsOn: "secret",
},
{
Label: "Agent ID",
Description: "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#apps create ApiApp",
Element: ElementTypeInput,
InputType: InputTypeText,
Placeholder: "1000002",
PropertyName: "agent_id",
Required: true,
DependsOn: "url",
},
{
Label: "Corp ID",
Description: "Required if using APIAPP, see https://work.weixin.qq.com/wework_admin/frame#profile",
Element: ElementTypeInput,
InputType: InputTypeText,
Placeholder: "wwxxxxxxxxx",
PropertyName: "corp_id",
Required: true,
DependsOn: "url",
},
{
Label: "Secret",
Description: "Required if using APIAPP",
Element: ElementTypeInput,
InputType: InputTypePassword,
Placeholder: "secret",
PropertyName: "secret",
Secure: true,
Required: true,
DependsOn: "url",
},
{
Label: "Message Type",
Element: ElementTypeSelect,
PropertyName: "msgtype",
SelectOptions: []SelectOption{
{
Value: "text",
Label: "Text",
},
{
Value: "markdown",
Label: "Markdown",
},
},
Placeholder: "Text",
},
{
Label: "Message",
@ -719,6 +768,13 @@ func GetAvailableNotifiers() []*NotifierPlugin {
PropertyName: "title",
Placeholder: `{{ template "default.title" . }}`,
},
{
Label: "To User",
Element: ElementTypeInput,
InputType: InputTypeText,
Placeholder: "@all",
PropertyName: "touser",
},
},
},
{