github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cmd/alert/add_receiver.go (about)

     1  /*
     2  Copyright (C) 2022-2023 ApeCloud Co., Ltd
     3  
     4  This file is part of KubeBlocks project
     5  
     6  This program is free software: you can redistribute it and/or modify
     7  it under the terms of the GNU Affero General Public License as published by
     8  the Free Software Foundation, either version 3 of the License, or
     9  (at your option) any later version.
    10  
    11  This program is distributed in the hope that it will be useful
    12  but WITHOUT ANY WARRANTY; without even the implied warranty of
    13  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    14  GNU Affero General Public License for more details.
    15  
    16  You should have received a copy of the GNU Affero General Public License
    17  along with this program.  If not, see <http://www.gnu.org/licenses/>.
    18  */
    19  
    20  package alert
    21  
    22  import (
    23  	"context"
    24  	"fmt"
    25  	"strconv"
    26  	"strings"
    27  
    28  	"github.com/spf13/cobra"
    29  	"golang.org/x/exp/slices"
    30  	corev1 "k8s.io/api/core/v1"
    31  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    32  	apitypes "k8s.io/apimachinery/pkg/types"
    33  	"k8s.io/cli-runtime/pkg/genericiooptions"
    34  	"k8s.io/client-go/kubernetes"
    35  	cmdutil "k8s.io/kubectl/pkg/cmd/util"
    36  	"k8s.io/kubectl/pkg/util/templates"
    37  	"sigs.k8s.io/yaml"
    38  
    39  	"github.com/1aal/kubeblocks/pkg/cli/util"
    40  )
    41  
    42  var (
    43  	// alertConfigmapName is the name of alertmanager configmap
    44  	alertConfigmapName = getConfigMapName(alertManagerAddonName)
    45  
    46  	// webhookAdaptorConfigmapName is the name of webhook adaptor
    47  	webhookAdaptorConfigmapName = getConfigMapName(webhookAdaptorAddonName)
    48  )
    49  
    50  var (
    51  	addReceiverExample = templates.Examples(`
    52  		# add webhook receiver without token, for example feishu
    53  		kbcli alert add-receiver --webhook='url=https://open.feishu.cn/open-apis/bot/v2/hook/foo'
    54  
    55  		# add webhook receiver with token, for example feishu
    56  		kbcli alert add-receiver --webhook='url=https://open.feishu.cn/open-apis/bot/v2/hook/foo,token=XXX'
    57  
    58  		# add email receiver
    59          kbcli alert add-receiver --email='user1@kubeblocks.io,user2@kubeblocks.io'
    60  
    61  		# add email receiver, and only receive alert from cluster mycluster
    62  		kbcli alert add-receiver --email='user1@kubeblocks.io,user2@kubeblocks.io' --cluster=mycluster
    63  
    64  		# add email receiver, and only receive alert from cluster mycluster and alert severity is warning
    65  		kbcli alert add-receiver --email='user1@kubeblocks.io,user2@kubeblocks.io' --cluster=mycluster --severity=warning
    66  
    67  		# add slack receiver
    68    		kbcli alert add-receiver --slack api_url=https://hooks.slackConfig.com/services/foo,channel=monitor,username=kubeblocks-alert-bot`)
    69  )
    70  
    71  type baseOptions struct {
    72  	genericiooptions.IOStreams
    73  	alertConfigMap   *corev1.ConfigMap
    74  	webhookConfigMap *corev1.ConfigMap
    75  	client           kubernetes.Interface
    76  }
    77  
    78  type addReceiverOptions struct {
    79  	baseOptions
    80  
    81  	emails     []string
    82  	webhooks   []string
    83  	slacks     []string
    84  	clusters   []string
    85  	severities []string
    86  	name       string
    87  
    88  	receiver                *receiver
    89  	route                   *route
    90  	webhookAdaptorReceivers []webhookAdaptorReceiver
    91  }
    92  
    93  func newAddReceiverCmd(f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Command {
    94  	o := addReceiverOptions{baseOptions: baseOptions{IOStreams: streams}}
    95  	cmd := &cobra.Command{
    96  		Use:     "add-receiver",
    97  		Short:   "Add alert receiver, such as email, slack, webhook and so on.",
    98  		Example: addReceiverExample,
    99  		Run: func(cmd *cobra.Command, args []string) {
   100  			util.CheckErr(o.complete(f))
   101  			util.CheckErr(o.validate(args))
   102  			util.CheckErr(o.run())
   103  		},
   104  	}
   105  
   106  	cmd.Flags().StringArrayVar(&o.emails, "email", []string{}, "Add email address, such as user@kubeblocks.io, more than one emailConfig can be specified separated by comma")
   107  	cmd.Flags().StringArrayVar(&o.webhooks, "webhook", []string{}, "Add webhook receiver, such as url=https://open.feishu.cn/open-apis/bot/v2/hook/foo,token=xxxxx")
   108  	cmd.Flags().StringArrayVar(&o.slacks, "slack", []string{}, "Add slack receiver, such as api_url=https://hooks.slackConfig.com/services/foo,channel=monitor,username=kubeblocks-alert-bot")
   109  	cmd.Flags().StringArrayVar(&o.clusters, "cluster", []string{}, "Cluster name, such as mycluster, more than one cluster can be specified, such as mycluster1,mycluster2")
   110  	cmd.Flags().StringArrayVar(&o.severities, "severity", []string{}, "Alert severity level, critical, warning or info, more than one severity level can be specified, such as critical,warning")
   111  
   112  	// register completions
   113  	util.CheckErr(cmd.RegisterFlagCompletionFunc("severity",
   114  		func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
   115  			return severities(), cobra.ShellCompDirectiveNoFileComp
   116  		}))
   117  
   118  	return cmd
   119  }
   120  
   121  func (o *baseOptions) complete(f cmdutil.Factory) error {
   122  	var err error
   123  	ctx := context.Background()
   124  
   125  	o.client, err = f.KubernetesClientSet()
   126  	if err != nil {
   127  		return err
   128  	}
   129  
   130  	namespace, err := util.GetKubeBlocksNamespace(o.client)
   131  	if err != nil {
   132  		return err
   133  	}
   134  
   135  	// get alertmanager configmap
   136  	o.alertConfigMap, err = o.client.CoreV1().ConfigMaps(namespace).Get(ctx, alertConfigmapName, metav1.GetOptions{})
   137  	if err != nil {
   138  		return err
   139  	}
   140  
   141  	// get webhook adaptor configmap
   142  	o.webhookConfigMap, err = o.client.CoreV1().ConfigMaps(namespace).Get(ctx, webhookAdaptorConfigmapName, metav1.GetOptions{})
   143  	return err
   144  }
   145  
   146  func (o *addReceiverOptions) validate(args []string) error {
   147  	if len(o.emails) == 0 && len(o.webhooks) == 0 && len(o.slacks) == 0 {
   148  		return fmt.Errorf("must specify at least one receiver, such as --email, --webhook or --slack")
   149  	}
   150  
   151  	// if name is not specified, generate a random one
   152  	if len(args) == 0 {
   153  		o.name = generateReceiverName()
   154  	} else {
   155  		o.name = args[0]
   156  	}
   157  
   158  	if err := o.checkEmails(); err != nil {
   159  		return err
   160  	}
   161  
   162  	if err := o.checkSeverities(); err != nil {
   163  		return err
   164  	}
   165  	return nil
   166  }
   167  
   168  // checkSeverities checks if severity is valid
   169  func (o *addReceiverOptions) checkSeverities() error {
   170  	if len(o.severities) == 0 {
   171  		return nil
   172  	}
   173  	checkSeverity := func(severity string) error {
   174  		ss := strings.Split(severity, ",")
   175  		for _, s := range ss {
   176  			if !slices.Contains(severities(), strings.ToLower(strings.TrimSpace(s))) {
   177  				return fmt.Errorf("invalid severity: %s, must be one of %v", s, severities())
   178  			}
   179  		}
   180  		return nil
   181  	}
   182  
   183  	for _, severity := range o.severities {
   184  		if err := checkSeverity(severity); err != nil {
   185  			return err
   186  		}
   187  	}
   188  	return nil
   189  }
   190  
   191  // checkEmails checks if email SMTP is configured, if not, do not allow to add email receiver
   192  func (o *addReceiverOptions) checkEmails() error {
   193  	if len(o.emails) == 0 {
   194  		return nil
   195  	}
   196  
   197  	errMsg := "SMTP %sis not configured, if you want to add email receiver, please use `kbcli alert config-smtpserver` configure it first"
   198  	data, err := getConfigData(o.alertConfigMap, alertConfigFileName)
   199  	if err != nil {
   200  		return err
   201  	}
   202  
   203  	if data["global"] == nil {
   204  		return fmt.Errorf(errMsg, "")
   205  	}
   206  
   207  	// check smtp config in global
   208  	checkKeys := []string{"smtp_from", "smtp_smarthost", "smtp_auth_username", "smtp_auth_password"}
   209  	checkSMTP := func(key string) error {
   210  		val := data["global"].(map[string]interface{})[key]
   211  		if val == nil || fmt.Sprintf("%v", val) == "" {
   212  			return fmt.Errorf(errMsg, key+" ")
   213  		}
   214  		return nil
   215  	}
   216  
   217  	for _, key := range checkKeys {
   218  		if err = checkSMTP(key); err != nil {
   219  			return err
   220  		}
   221  	}
   222  	return nil
   223  }
   224  
   225  func (o *addReceiverOptions) run() error {
   226  	// build receiver
   227  	if err := o.buildReceiver(); err != nil {
   228  		return err
   229  	}
   230  
   231  	// build route
   232  	o.buildRoute()
   233  
   234  	// add alertmanager receiver and route
   235  	if err := o.addReceiver(); err != nil {
   236  		return err
   237  	}
   238  
   239  	// add webhook receiver
   240  	if err := o.addWebhookReceivers(); err != nil {
   241  		return err
   242  	}
   243  
   244  	fmt.Fprintf(o.Out, "Receiver %s added successfully.\n", o.receiver.Name)
   245  	return nil
   246  }
   247  
   248  // buildReceiver builds receiver from receiver options
   249  func (o *addReceiverOptions) buildReceiver() error {
   250  	webhookConfigs, err := o.buildWebhook()
   251  	if err != nil {
   252  		return err
   253  	}
   254  
   255  	slackConfigs, err := buildSlackConfigs(o.slacks)
   256  	if err != nil {
   257  		return err
   258  	}
   259  
   260  	o.receiver = &receiver{
   261  		Name:           o.name,
   262  		EmailConfigs:   buildEmailConfigs(o.emails),
   263  		WebhookConfigs: webhookConfigs,
   264  		SlackConfigs:   slackConfigs,
   265  	}
   266  	return nil
   267  }
   268  
   269  func (o *addReceiverOptions) buildRoute() {
   270  	r := &route{
   271  		Receiver: o.name,
   272  		Continue: true,
   273  	}
   274  
   275  	var clusterArray []string
   276  	var severityArray []string
   277  
   278  	splitStr := func(strArray []string, target *[]string) {
   279  		for _, s := range strArray {
   280  			ss := strings.Split(s, ",")
   281  			*target = append(*target, ss...)
   282  		}
   283  	}
   284  
   285  	// parse clusters and severities
   286  	splitStr(o.clusters, &clusterArray)
   287  	splitStr(o.severities, &severityArray)
   288  
   289  	// build matchers
   290  	buildMatchers := func(t string, values []string) string {
   291  		if len(values) == 0 {
   292  			return ""
   293  		}
   294  		deValues := removeDuplicateStr(values)
   295  		switch t {
   296  		case routeMatcherClusterType:
   297  			return routeMatcherClusterKey + routeMatcherOperator + strings.Join(deValues, "|")
   298  		case routeMatcherSeverityType:
   299  			return routeMatcherSeverityKey + routeMatcherOperator + strings.Join(deValues, "|")
   300  		default:
   301  			return ""
   302  		}
   303  	}
   304  
   305  	r.Matchers = append(r.Matchers, buildMatchers(routeMatcherClusterType, clusterArray),
   306  		buildMatchers(routeMatcherSeverityType, severityArray))
   307  	o.route = r
   308  }
   309  
   310  // addReceiver adds receiver to alertmanager config
   311  func (o *addReceiverOptions) addReceiver() error {
   312  	data, err := getConfigData(o.alertConfigMap, alertConfigFileName)
   313  	if err != nil {
   314  		return err
   315  	}
   316  
   317  	// add receiver
   318  	receivers := getReceiversFromData(data)
   319  	if receiverExists(receivers, o.name) {
   320  		return fmt.Errorf("receiver %s already exists", o.receiver.Name)
   321  	}
   322  	receivers = append(receivers, o.receiver)
   323  
   324  	// add route
   325  	routes := getRoutesFromData(data)
   326  	routes = append(routes, o.route)
   327  
   328  	data["receivers"] = receivers
   329  	data["route"].(map[string]interface{})["routes"] = routes
   330  
   331  	// update alertmanager configmap
   332  	return updateConfig(o.client, o.alertConfigMap, alertConfigFileName, data)
   333  }
   334  
   335  func (o *addReceiverOptions) addWebhookReceivers() error {
   336  	data, err := getConfigData(o.webhookConfigMap, webhookAdaptorFileName)
   337  	if err != nil {
   338  		return err
   339  	}
   340  
   341  	receivers := getReceiversFromData(data)
   342  	for _, r := range o.webhookAdaptorReceivers {
   343  		receivers = append(receivers, r)
   344  	}
   345  	data["receivers"] = receivers
   346  
   347  	// update webhook configmap
   348  	return updateConfig(o.client, o.webhookConfigMap, webhookAdaptorFileName, data)
   349  }
   350  
   351  // buildWebhook builds webhookConfig and webhookAdaptorReceiver from webhook options
   352  func (o *addReceiverOptions) buildWebhook() ([]*webhookConfig, error) {
   353  	var ws []*webhookConfig
   354  	var waReceivers []webhookAdaptorReceiver
   355  	for _, hook := range o.webhooks {
   356  		m := strToMap(hook)
   357  		if len(m) == 0 {
   358  			return nil, fmt.Errorf("invalid webhook: %s, webhook should be in the format of url=my-url,token=my-token", hook)
   359  		}
   360  		w := webhookConfig{
   361  			MaxAlerts:    10,
   362  			SendResolved: false,
   363  		}
   364  		waReceiver := webhookAdaptorReceiver{Name: o.name}
   365  		for k, v := range m {
   366  			// check webhookConfig keys
   367  			switch webhookKey(k) {
   368  			case webhookURL:
   369  				if valid, err := urlIsValid(v); !valid {
   370  					return nil, fmt.Errorf("invalid webhook url: %s, %v", v, err)
   371  				}
   372  				w.URL = getWebhookAdaptorURL(o.name, o.webhookConfigMap.Namespace)
   373  				webhookType := getWebhookType(v)
   374  				if webhookType == unknownWebhookType {
   375  					return nil, fmt.Errorf("invalid webhook url: %s, failed to prase the webhook type", v)
   376  				}
   377  				waReceiver.Type = string(webhookType)
   378  				waReceiver.Params.URL = v
   379  			case webhookToken:
   380  				waReceiver.Params.Secret = v
   381  			default:
   382  				return nil, fmt.Errorf("invalid webhook key: %s, webhook key should be one of url and token", k)
   383  			}
   384  		}
   385  		ws = append(ws, &w)
   386  		waReceivers = append(waReceivers, waReceiver)
   387  	}
   388  	o.webhookAdaptorReceivers = waReceivers
   389  	return ws, nil
   390  }
   391  
   392  func receiverExists(receivers []interface{}, name string) bool {
   393  	for _, r := range receivers {
   394  		n := r.(map[string]interface{})["name"]
   395  		if n != nil && n.(string) == name {
   396  			return true
   397  		}
   398  	}
   399  	return false
   400  }
   401  
   402  // buildSlackConfigs builds slackConfig from slack options
   403  func buildSlackConfigs(slacks []string) ([]*slackConfig, error) {
   404  	var ss []*slackConfig
   405  	for _, slackStr := range slacks {
   406  		m := strToMap(slackStr)
   407  		if len(m) == 0 {
   408  			return nil, fmt.Errorf("invalid slack: %s, slack config should be in the format of api_url=my-api-url,channel=my-channel,username=my-username", slackStr)
   409  		}
   410  		s := slackConfig{TitleLink: ""}
   411  		for k, v := range m {
   412  			// check slackConfig keys
   413  			switch slackKey(k) {
   414  			case slackAPIURL:
   415  				if valid, err := urlIsValid(v); !valid {
   416  					return nil, fmt.Errorf("invalid slack api_url: %s, %v", v, err)
   417  				}
   418  				s.APIURL = v
   419  			case slackChannel:
   420  				s.Channel = "#" + v
   421  			case slackUsername:
   422  				s.Username = v
   423  			default:
   424  				return nil, fmt.Errorf("invalid slack config key: %s", k)
   425  			}
   426  		}
   427  		ss = append(ss, &s)
   428  	}
   429  	return ss, nil
   430  }
   431  
   432  // buildEmailConfigs builds emailConfig from email options
   433  func buildEmailConfigs(emails []string) []*emailConfig {
   434  	var es []*emailConfig
   435  	for _, email := range emails {
   436  		strs := strings.Split(email, ",")
   437  		for _, str := range strs {
   438  			es = append(es, &emailConfig{To: str})
   439  		}
   440  	}
   441  	return es
   442  }
   443  
   444  func updateConfig(client kubernetes.Interface, cm *corev1.ConfigMap, key string, data map[string]interface{}) error {
   445  	newValue, err := yaml.Marshal(data)
   446  	if err != nil {
   447  		return err
   448  	}
   449  	_, err = client.CoreV1().ConfigMaps(cm.Namespace).Patch(context.TODO(), cm.Name, apitypes.JSONPatchType,
   450  		[]byte(fmt.Sprintf("[{\"op\": \"replace\", \"path\": \"/data/%s\", \"value\": %s }]",
   451  			key, strconv.Quote(string(newValue)))), metav1.PatchOptions{})
   452  	return err
   453  }