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 }