grafana/pkg/services/ngalert/migration/channel.go
Matthew Jacobson 46a77c0074
Alerting: Validate upgraded receivers early to display in preview (#82956)
Previously receivers were only validated before saving the alertmanager
configuration. This is a suboptimal experience for those upgrading with preview
as the failed channel upgrade will return an API error instead of being
summarized in the table.
2024-02-16 15:17:07 -05:00

216 lines
7.7 KiB
Go

package migration
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
legacymodels "github.com/grafana/grafana/pkg/services/alerting/models"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
migmodels "github.com/grafana/grafana/pkg/services/ngalert/migration/models"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/secrets"
)
const (
// DisabledRepeatInterval is a large duration that will be used as a pseudo-disable in case a legacy channel doesn't have SendReminders enabled.
DisabledRepeatInterval = model.Duration(time.Duration(8736) * time.Hour) // 1y
)
// ErrDiscontinued is used for channels that are no longer supported after migration.
var ErrDiscontinued = errors.New("discontinued")
// migrateChannels creates Alertmanager configs with migrated receivers and routes.
func (om *OrgMigration) migrateChannels(channels []*legacymodels.AlertNotification, log log.Logger) ([]*migmodels.ContactPair, error) {
// Create all newly migrated receivers from legacy notification channels.
pairs := make([]*migmodels.ContactPair, 0, len(channels))
for _, c := range channels {
l := log.New("type", c.Type, "name", c.Name, "uid", c.UID)
pair := &migmodels.ContactPair{
Channel: c,
}
receiver, err := om.createReceiver(c)
if err != nil {
l.Warn("Failed to create receiver", "error", err)
pair.Error = err
pairs = append(pairs, pair)
continue
}
pair.ContactPoint = receiver
route, err := createRoute(c, receiver.Name)
if err != nil {
l.Warn("Failed to create route", "error", err)
pair.Error = err
pairs = append(pairs, pair)
continue
}
pair.Route = route
pairs = append(pairs, pair)
}
return pairs, nil
}
// createNotifier creates a PostableGrafanaReceiver from a legacy notification channel.
func (om *OrgMigration) createReceiver(c *legacymodels.AlertNotification) (*apimodels.PostableGrafanaReceiver, error) {
if c.Type == "hipchat" || c.Type == "sensu" {
return nil, fmt.Errorf("'%s': %w", c.Type, ErrDiscontinued)
}
settings, secureSettings, err := om.migrateSettingsToSecureSettings(c.Type, c.Settings, c.SecureSettings)
if err != nil {
return nil, err
}
data, err := settings.MarshalJSON()
if err != nil {
return nil, err
}
recv := &apimodels.PostableGrafanaReceiver{
UID: c.UID,
Name: c.Name,
Type: c.Type,
DisableResolveMessage: c.DisableResolveMessage,
Settings: data,
SecureSettings: secureSettings,
}
err = validateReceiver(recv, om.encryptionService.GetDecryptedValue)
if err != nil {
return nil, err
}
return recv, nil
}
// validateReceiver validates a receiver by building the configuration and checking for errors.
func validateReceiver(receiver *apimodels.PostableGrafanaReceiver, decrypt func(ctx context.Context, sjd map[string][]byte, key, fallback string) string) error {
var (
cfg = &alertingNotify.GrafanaIntegrationConfig{
UID: receiver.UID,
Name: receiver.Name,
Type: receiver.Type,
DisableResolveMessage: receiver.DisableResolveMessage,
Settings: json.RawMessage(receiver.Settings),
SecureSettings: receiver.SecureSettings,
}
)
_, err := alertingNotify.BuildReceiverConfiguration(context.Background(), &alertingNotify.APIReceiver{
GrafanaIntegrations: alertingNotify.GrafanaIntegrations{Integrations: []*alertingNotify.GrafanaIntegrationConfig{cfg}},
}, decrypt)
return err
}
// createRoute creates a route from a legacy notification channel, and matches using a label based on the channel UID.
func createRoute(channel *legacymodels.AlertNotification, receiverName string) (*apimodels.Route, error) {
// We create a matchers based on channel name so that we only need a single route per channel.
// All channel routes are nested in a single route under the root. This is so we can keep the migrated channels separate
// and organized.
// Since default channels are attached to all alerts in legacy, we use a catch-all matcher after migration instead
// of a specific label matcher.
//
// For example, if an alert needs to send to channel1 and channel2 it will have one label to route to the nested
// policy and two channel-specific labels to route to the correct contact points:
// - __legacy_use_channels__="true"
// - __legacy_c_channel1__="true"
// - __legacy_c_channel2__="true"
//
// If an alert needs to send to channel1 and the default channel, it will have one label to route to the nested
// policy and one channel-specific label to route to channel1, and a catch-all policy will ensure it also routes to
// the default channel.
label := contactLabel(channel.Name)
mat, err := labels.NewMatcher(labels.MatchEqual, label, "true")
if err != nil {
return nil, err
}
// If the channel is default, we create a catch-all matcher instead so this always matches.
if channel.IsDefault {
mat, _ = labels.NewMatcher(labels.MatchRegexp, model.AlertNameLabel, ".+")
}
repeatInterval := DisabledRepeatInterval
if channel.SendReminder {
repeatInterval = model.Duration(channel.Frequency)
}
return &apimodels.Route{
Receiver: receiverName,
ObjectMatchers: apimodels.ObjectMatchers{mat},
Continue: true, // We continue so that each sibling contact point route can separately match.
RepeatInterval: &repeatInterval,
}, nil
}
// contactLabel creates a label matcher key used to route alerts to a contact point.
func contactLabel(name string) string {
return ngmodels.MigratedContactLabelPrefix + name + "__"
}
var secureKeysToMigrate = map[string][]string{
"slack": {"url", "token"},
"pagerduty": {"integrationKey"},
"webhook": {"password"},
"prometheus-alertmanager": {"basicAuthPassword"},
"opsgenie": {"apiKey"},
"telegram": {"bottoken"},
"line": {"token"},
"pushover": {"apiToken", "userKey"},
"threema": {"api_secret"},
}
// Some settings were migrated from settings to secure settings in between.
// See https://grafana.com/docs/grafana/latest/installation/upgrading/#ensure-encryption-of-existing-alert-notification-channel-secrets.
// migrateSettingsToSecureSettings takes care of that.
func (om *OrgMigration) migrateSettingsToSecureSettings(chanType string, settings *simplejson.Json, secureSettings SecureJsonData) (*simplejson.Json, map[string]string, error) {
keys := secureKeysToMigrate[chanType]
newSecureSettings := secureSettings.Decrypt(om.cfg.SecretKey)
cloneSettings := simplejson.New()
settingsMap, err := settings.Map()
if err != nil {
return nil, nil, err
}
for k, v := range settingsMap {
cloneSettings.Set(k, v)
}
for _, k := range keys {
if v, ok := newSecureSettings[k]; ok && v != "" {
continue
}
sv := cloneSettings.Get(k).MustString()
if sv != "" {
newSecureSettings[k] = sv
cloneSettings.Del(k)
}
}
err = om.encryptSecureSettings(newSecureSettings)
if err != nil {
return nil, nil, err
}
return cloneSettings, newSecureSettings, nil
}
func (om *OrgMigration) encryptSecureSettings(secureSettings map[string]string) error {
for key, value := range secureSettings {
encryptedData, err := om.encryptionService.Encrypt(context.Background(), []byte(value), secrets.WithoutScope())
if err != nil {
return fmt.Errorf("encrypt secure settings: %w", err)
}
secureSettings[key] = base64.StdEncoding.EncodeToString(encryptedData)
}
return nil
}