Alerting: Migrate to integration schema (#111643)

* update tests to assert against snapshot
* remove channel_config package replaced by schemas from alerting module
* update  references to use new schema
This commit is contained in:
Yuri Tseretyan
2025-09-26 09:31:50 -04:00
committed by GitHub
parent a8bff45256
commit b8f23eacd4
23 changed files with 4475 additions and 4255 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,290 +0,0 @@
package channels_config
import (
"encoding/json"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"testing"
"github.com/grafana/alerting/notify/notifytest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetSecretKeysForContactPointType(t *testing.T) {
httpConfigSecrets := []string{"http_config.authorization.credentials", "http_config.basic_auth.password", "http_config.oauth2.client_secret"}
testCases := []struct {
receiverType string
version NotifierVersion
expectedSecretFields []string
}{
{receiverType: "dingding", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "kafka", version: V1, expectedSecretFields: []string{"password"}},
{receiverType: "email", version: V1, expectedSecretFields: []string{}},
{receiverType: "pagerduty", version: V1, expectedSecretFields: []string{"integrationKey"}},
{receiverType: "victorops", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "oncall", version: V1, expectedSecretFields: []string{"password", "authorization_credentials"}},
{receiverType: "pushover", version: V1, expectedSecretFields: []string{"apiToken", "userKey"}},
{receiverType: "slack", version: V1, expectedSecretFields: []string{"token", "url"}},
{receiverType: "sensugo", version: V1, expectedSecretFields: []string{"apikey"}},
{receiverType: "teams", version: V1, expectedSecretFields: []string{}},
{receiverType: "telegram", version: V1, expectedSecretFields: []string{"bottoken"}},
{receiverType: "webhook", version: V1, expectedSecretFields: []string{
"password",
"authorization_credentials",
"tlsConfig.caCertificate",
"tlsConfig.clientCertificate",
"tlsConfig.clientKey",
"hmacConfig.secret",
"http_config.oauth2.client_secret",
"http_config.oauth2.tls_config.caCertificate",
"http_config.oauth2.tls_config.clientCertificate",
"http_config.oauth2.tls_config.clientKey",
}},
{receiverType: "wecom", version: V1, expectedSecretFields: []string{"url", "secret"}},
{receiverType: "prometheus-alertmanager", version: V1, expectedSecretFields: []string{"basicAuthPassword"}},
{receiverType: "discord", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "googlechat", version: V1, expectedSecretFields: []string{"url"}},
{receiverType: "LINE", version: V1, expectedSecretFields: []string{"token"}},
{receiverType: "threema", version: V1, expectedSecretFields: []string{"api_secret"}},
{receiverType: "opsgenie", version: V1, expectedSecretFields: []string{"apiKey"}},
{receiverType: "webex", version: V1, expectedSecretFields: []string{"bot_token"}},
{receiverType: "sns", version: V1, expectedSecretFields: []string{"sigv4.access_key", "sigv4.secret_key"}},
{receiverType: "mqtt", version: V1, expectedSecretFields: []string{"password", "tlsConfig.caCertificate", "tlsConfig.clientCertificate", "tlsConfig.clientKey"}},
{receiverType: "jira", version: V1, expectedSecretFields: []string{"user", "password", "api_token"}},
{receiverType: "victorops", version: V0mimir1, expectedSecretFields: append([]string{"api_key"}, httpConfigSecrets...)},
{receiverType: "sns", version: V0mimir1, expectedSecretFields: append([]string{"sigv4.SecretKey"}, httpConfigSecrets...)},
{receiverType: "telegram", version: V0mimir1, expectedSecretFields: append([]string{"token"}, httpConfigSecrets...)},
{receiverType: "discord", version: V0mimir1, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)},
{receiverType: "pagerduty", version: V0mimir1, expectedSecretFields: append([]string{"routing_key", "service_key"}, httpConfigSecrets...)},
{receiverType: "pushover", version: V0mimir1, expectedSecretFields: append([]string{"user_key", "token"}, httpConfigSecrets...)},
{receiverType: "jira", version: V0mimir1, expectedSecretFields: httpConfigSecrets},
{receiverType: "opsgenie", version: V0mimir1, expectedSecretFields: append([]string{"api_key"}, httpConfigSecrets...)},
{receiverType: "teams", version: V0mimir1, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)},
{receiverType: "teams", version: V0mimir2, expectedSecretFields: append([]string{"webhook_url"}, httpConfigSecrets...)},
{receiverType: "email", version: V0mimir1, expectedSecretFields: []string{"auth_password", "auth_secret"}},
{receiverType: "slack", version: V0mimir1, expectedSecretFields: append([]string{"api_url"}, httpConfigSecrets...)},
{receiverType: "webex", version: V0mimir1, expectedSecretFields: httpConfigSecrets},
{receiverType: "wechat", version: V0mimir1, expectedSecretFields: append([]string{"api_secret"}, httpConfigSecrets...)},
{receiverType: "webhook", version: V0mimir1, expectedSecretFields: append([]string{"url"}, httpConfigSecrets...)},
}
n := slices.Collect(GetAvailableNotifiersV2())
type typeWithVersion struct {
Type string
Version NotifierVersion
}
allTypes := make(map[typeWithVersion]struct{}, len(n))
getKey := func(pluginType string, version NotifierVersion) typeWithVersion {
return typeWithVersion{pluginType, version}
}
for _, p := range n {
for _, v := range p.Versions {
allTypes[getKey(p.Type, v.Version)] = struct{}{}
}
}
for _, testCase := range testCases {
delete(allTypes, getKey(testCase.receiverType, testCase.version))
t.Run(fmt.Sprintf("%s-%s", testCase.receiverType, testCase.version), func(t *testing.T) {
got, err := GetSecretKeysForContactPointType(testCase.receiverType, testCase.version)
require.NoError(t, err)
require.ElementsMatch(t, testCase.expectedSecretFields, got)
})
}
for it := range allTypes {
t.Run(fmt.Sprintf("%s-%s", it.Type, it.Version), func(t *testing.T) {
got, err := GetSecretKeysForContactPointType(it.Type, it.Version)
require.NoError(t, err)
require.Emptyf(t, got, "secret keys for version %s of %s should be empty", it.Version, it.Type)
})
}
require.Emptyf(t, allTypes, "not all types are covered: %s", allTypes)
}
func TestGetAvailableNotifiersV2(t *testing.T) {
n := slices.Collect(GetAvailableNotifiersV2())
require.NotEmpty(t, n)
for _, notifier := range n {
t.Run(fmt.Sprintf("integration %s [%s]", notifier.Type, notifier.Name), func(t *testing.T) {
currentVersion := V1
if notifier.Type == "wechat" {
currentVersion = V0mimir1
}
t.Run(fmt.Sprintf("current version is %s", currentVersion), func(t *testing.T) {
require.Equal(t, currentVersion, notifier.GetCurrentVersion().Version)
})
t.Run("should be able to create only v1", func(t *testing.T) {
for _, version := range notifier.Versions {
if version.Version == V1 {
require.True(t, version.CanCreate, "v1 should be able to create")
continue
}
require.False(t, version.CanCreate, "v0 should not be able to create")
}
})
})
}
}
func TestConfigForIntegrationType(t *testing.T) {
t.Run("should return current version for all common types", func(t *testing.T) {
for plugin := range GetAvailableNotifiersV2() {
t.Run(plugin.Type, func(t *testing.T) {
version, err := ConfigForIntegrationType(plugin.Type)
require.NoErrorf(t, err, "expected config but got error for plugin type %s", plugin.Type)
assert.Equal(t, version.Plugin, plugin)
assert.Equal(t, version, plugin.GetCurrentVersion())
})
}
})
t.Run("should return specific version if matched by alias", func(t *testing.T) {
for plugin := range GetAvailableNotifiersV2() {
for _, version := range plugin.Versions {
if version.TypeAlias == "" {
continue
}
t.Run(version.TypeAlias, func(t *testing.T) {
actualVersion, err := ConfigForIntegrationType(version.TypeAlias)
require.NoErrorf(t, err, "expected config but got error for plugin type %s", plugin.Type)
assert.Equal(t, version, actualVersion)
})
}
}
})
t.Run("should return error if not known type", func(t *testing.T) {
_, err := ConfigForIntegrationType("unknown")
require.Error(t, err)
})
}
func TestTypeUniqueness(t *testing.T) {
knownTypes := make(map[string]struct{})
for plugin := range GetAvailableNotifiersV2() {
iType := strings.ToLower(plugin.Type)
if _, ok := knownTypes[iType]; ok {
assert.Failf(t, "duplicate plugin type", "plugin type %s", plugin.Type)
}
knownTypes[iType] = struct{}{}
for _, version := range plugin.Versions {
if version.TypeAlias == "" {
continue
}
iType = strings.ToLower(version.TypeAlias)
if _, ok := knownTypes[iType]; ok {
assert.Failf(t, "mimir type duplicates Grafana plugin type", "plugin type %s", iType)
}
knownTypes[iType] = struct{}{}
}
}
}
func Test_getSecretFields(t *testing.T) {
testCases := []struct {
name string
parentPath string
options []NotifierOption
expectedFields []string
}{
{
name: "No secure fields",
parentPath: "",
options: []NotifierOption{
{PropertyName: "field1", Secure: false, SubformOptions: nil},
{PropertyName: "field2", Secure: false, SubformOptions: nil},
},
expectedFields: []string{},
},
{
name: "Single secure field",
parentPath: "",
options: []NotifierOption{
{PropertyName: "field1", Secure: true, SubformOptions: nil},
{PropertyName: "field2", Secure: false, SubformOptions: nil},
},
expectedFields: []string{"field1"},
},
{
name: "Secure field in subform",
parentPath: "parent",
options: []NotifierOption{
{PropertyName: "field1", Secure: true, SubformOptions: nil},
{PropertyName: "field2", Secure: false, SubformOptions: []NotifierOption{
{PropertyName: "subfield1", Secure: true, SubformOptions: nil},
}},
},
expectedFields: []string{"parent.field1", "parent.field2.subfield1"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := getSecretFields(tc.parentPath, tc.options)
require.ElementsMatch(t, got, tc.expectedFields)
})
}
}
func TestV0IntegrationsSecrets(t *testing.T) {
// This test ensures that all known integrations' secrets are listed in the schema definition.
notifytest.ForEachIntegrationType(t, func(configType reflect.Type) {
t.Run(configType.Name(), func(t *testing.T) {
integrationType := strings.ToLower(strings.TrimSuffix(configType.Name(), "Config"))
pluginVersion, err := ConfigForIntegrationType(integrationType)
require.NoError(t, err)
if pluginVersion.Version == V1 {
var ok bool
pluginVersion, ok = pluginVersion.Plugin.GetVersion(V0mimir1)
require.True(t, ok)
}
expectedSecrets := pluginVersion.GetSecretFieldsPaths()
var secrets []string
for option := range maps.Keys(notifytest.ValidMimirHTTPConfigs) {
cfg, err := notifytest.GetMimirIntegrationForType(configType, option)
require.NoError(t, err)
data, err := json.Marshal(cfg)
require.NoError(t, err)
m := map[string]any{}
err = json.Unmarshal(data, &m)
require.NoError(t, err)
secrets = append(secrets, getSecrets(m, "")...)
}
secrets = unique(secrets)
t.Log(secrets)
require.ElementsMatch(t, expectedSecrets, secrets)
})
})
}
func unique(slice []string) []string {
keys := make(map[string]struct{}, len(slice))
list := make([]string, 0, len(slice))
for _, entry := range slice {
if _, value := keys[entry]; !value {
keys[entry] = struct{}{}
list = append(list, entry)
}
}
return list
}
func getSecrets(m map[string]any, parent string) []string {
var result []string
for key, val := range m {
str, ok := val.(string)
if ok && str == "<secret>" {
result = append(result, parent+key)
}
m, ok := val.(map[string]any)
if ok {
subSecrets := getSecrets(m, parent+key+".")
result = append(result, subSecrets...)
}
}
return result
}
@@ -1,122 +0,0 @@
package channels_config
// NotifierPlugin holds meta information about a notifier.
type NotifierPlugin struct {
Type string `json:"type"`
TypeAlias string `json:"typeAlias,omitempty"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
Options []NotifierOption `json:"options"`
}
// VersionedNotifierPlugin represents a notifier plugin with multiple versions and detailed configuration options.
// It includes metadata such as type, name, description, and version-specific details.
type VersionedNotifierPlugin struct {
Type string `json:"type"`
CurrentVersion NotifierVersion `json:"currentVersion"`
Name string `json:"name"`
Heading string `json:"heading,omitempty"`
Description string `json:"description,omitempty"`
Info string `json:"info,omitempty"`
Versions []NotifierPluginVersion `json:"versions"`
}
// GetVersion retrieves a specific version of the notifier plugin by its version string. Returns the version and a boolean indicating success.
func (p VersionedNotifierPlugin) GetVersion(v NotifierVersion) (NotifierPluginVersion, bool) {
for _, version := range p.Versions {
if version.Version == v {
return version, true
}
}
return NotifierPluginVersion{}, false
}
// GetCurrentVersion retrieves the current version of the notifier plugin based on the CurrentVersion property.
// Panics if the version specified in CurrentVersion is not found in the configured versions.
func (p VersionedNotifierPlugin) GetCurrentVersion() NotifierPluginVersion {
v, ok := p.GetVersion(p.CurrentVersion)
if !ok {
panic("version not found for current version: " + p.CurrentVersion)
}
return v
}
// NotifierPluginVersion represents a version of a notifier plugin, including configuration options and metadata.
type NotifierPluginVersion struct {
TypeAlias string `json:"typeAlias,omitempty"`
Version NotifierVersion `json:"version"`
CanCreate bool `json:"canCreate"`
Options []NotifierOption `json:"options"`
Info string `json:"info,omitempty"`
Plugin *VersionedNotifierPlugin `json:"-"`
}
// GetSecretFieldsPaths returns a list of paths for fields marked as secure within the NotifierPluginVersion's options.
func (v NotifierPluginVersion) GetSecretFieldsPaths() []string {
return getSecretFields("", v.Options)
}
// NotifierOption holds information about options specific for the NotifierPlugin.
type NotifierOption struct {
Element ElementType `json:"element"`
InputType InputType `json:"inputType"`
Label string `json:"label"`
Description string `json:"description"`
Placeholder string `json:"placeholder"`
PropertyName string `json:"propertyName"`
SelectOptions []SelectOption `json:"selectOptions"`
ShowWhen ShowWhen `json:"showWhen"`
Required bool `json:"required"`
ValidationRule string `json:"validationRule"`
Secure bool `json:"secure"`
DependsOn string `json:"dependsOn"`
SubformOptions []NotifierOption `json:"subformOptions"`
}
// ElementType is the type of element that can be rendered in the frontend.
type ElementType string
const (
// ElementTypeInput will render an input
ElementTypeInput = "input"
// ElementTypeSelect will render a select
ElementTypeSelect = "select"
// ElementTypeCheckbox will render a checkbox
ElementTypeCheckbox = "checkbox"
// ElementTypeTextArea will render a textarea
ElementTypeTextArea = "textarea"
// ElementTypeKeyValueMap will render inputs to add arbitrary key-value pairs
ElementTypeKeyValueMap = "key_value_map"
// ElementSubformArray will render a sub-form with schema defined in SubformOptions
ElementTypeSubform = "subform"
// ElementSubformArray will render a multiple sub-forms with schema defined in SubformOptions
ElementSubformArray = "subform_array"
// ElementStringArray will render a set of fields to manage an array of strings.
ElementStringArray = "string_array"
)
// InputType is the type of input that can be rendered in the frontend.
type InputType string
const (
// InputTypeText will render a text field in the frontend
InputTypeText = "text"
// InputTypePassword will render a password field in the frontend
InputTypePassword = "password"
)
// SelectOption is a simple type for Options that have dropdown options. Should be used when Element is ElementTypeSelect.
type SelectOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// ShowWhen holds information about when options are dependant on other options.
// Should be used when Element is ElementTypeSelect.
// Does not work for ElementTypeCheckbox.
type ShowWhen struct {
Field string `json:"field"`
Is string `json:"is"`
}
+7 -5
View File
@@ -8,10 +8,12 @@ import (
"fmt"
"strings"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets"
@@ -107,11 +109,11 @@ func encryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt defini
return fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", gr.Type, gr.Name, err)
}
secretKeys, err := channels_config.GetSecretKeysForContactPointType(gr.Type, channels_config.V1)
if err != nil {
return fmt.Errorf("failed to get secret keys for contact point type %s: %w", gr.Type, err)
typeSchema, ok := alertingNotify.GetSchemaVersionForIntegration(schema.IntegrationType(gr.Type), schema.V1)
if !ok {
return fmt.Errorf("failed to get secret keys for contact point type %s", gr.Type)
}
secretKeys := typeSchema.GetSecretFieldsPaths()
secureSettings := gr.SecureSettings
if secureSettings == nil {
secureSettings = make(map[string]string)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
alertingImages "github.com/grafana/alerting/images"
"github.com/grafana/alerting/receivers"
alertingEmail "github.com/grafana/alerting/receivers/email"
alertingEmail "github.com/grafana/alerting/receivers/email/v1"
alertingTemplates "github.com/grafana/alerting/templates"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
@@ -8,13 +8,14 @@ import (
"github.com/grafana/alerting/definition"
"github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/alerting/receivers/webhook"
"github.com/prometheus/alertmanager/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
)
func TestReceiverInUse(t *testing.T) {
@@ -91,8 +92,9 @@ func TestDeleteReceiver(t *testing.T) {
}
func TestCreateReceiver(t *testing.T) {
rawCfg := notify.AllKnownConfigsForTesting["webhook"]
cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1"))
rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)]
typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type)
cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1)
require.NoError(t, err)
settings := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings))
@@ -197,8 +199,9 @@ func TestCreateReceiver(t *testing.T) {
}
func TestUpdateReceiver(t *testing.T) {
rawCfg := notify.AllKnownConfigsForTesting["webhook"]
cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1"))
rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)]
typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type)
cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1)
require.NoError(t, err)
settings := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings))
@@ -297,8 +300,9 @@ func TestUpdateReceiver(t *testing.T) {
}
func TestGetReceiver(t *testing.T) {
rawCfg := notify.AllKnownConfigsForTesting["webhook"]
cfgSchema, err := models.IntegrationConfigFromType(rawCfg.NotifierType, util.Pointer("v1"))
rawCfg := notify.AllKnownConfigsForTesting[string(webhook.Type)]
typeSchema, _ := notify.GetSchemaForIntegration(webhook.Type)
cfgSchema, err := models.IntegrationConfigFromSchema(typeSchema, schema.V1)
require.NoError(t, err)
settings := map[string]any{}
require.NoError(t, json.Unmarshal([]byte(rawCfg.Config), &settings))