Alerting: Update integration schema to support versions (#109969)

* add VersionedNotifierPlugin and method that converts NotifierPlugin to it

* return new schema if query parameter version=2

* add version to k8s model of integration

* fix open api snapshot

* add version to IntegrationConfig

* use current version on conversion

* create versioned integrations for test
This commit is contained in:
Yuri Tseretyan
2025-08-28 14:46:30 -04:00
committed by GitHub
parent 3a2483a73a
commit 15fab1cb99
13 changed files with 161 additions and 30 deletions
@@ -8,6 +8,7 @@ ReceiverSpec: {
#Integration: {
uid?: string
type: string
version: string
disableResolveMessage?: bool
settings: {
[string]: _
@@ -6,6 +6,7 @@ package v0alpha1
type ReceiverIntegration struct {
Uid *string `json:"uid,omitempty"`
Type string `json:"type"`
Version string `json:"version"`
DisableResolveMessage *bool `json:"disableResolveMessage,omitempty"`
Settings map[string]interface{} `json:"settings"`
SecureFields map[string]bool `json:"secureFields,omitempty"`
@@ -108,6 +108,13 @@ func schema_pkg_apis_alerting_v0alpha1_ReceiverIntegration(ref common.ReferenceC
Format: "",
},
},
"version": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"disableResolveMessage": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
@@ -144,7 +151,7 @@ func schema_pkg_apis_alerting_v0alpha1_ReceiverIntegration(ref common.ReferenceC
},
},
},
Required: []string{"type", "settings"},
Required: []string{"type", "version", "settings"},
},
},
}
+5 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"slices"
"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
@@ -9,7 +10,10 @@ import (
)
func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response {
return func(_ *contextmodel.ReqContext) response.Response {
return func(r *contextmodel.ReqContext) response.Response {
if r.Query("version") == "2" {
return response.JSON(http.StatusOK, slices.Collect(channels_config.GetAvailableNotifiersV2()))
}
return response.JSON(http.StatusOK, channels_config.GetAvailableNotifiers())
}
}
@@ -68,6 +68,7 @@ func convertToK8sResource(
DisableResolveMessage: &integration.DisableResolveMessage,
Settings: maps.Clone(integration.Settings),
SecureFields: integration.SecureFields(),
Version: integration.Config.Version,
})
}
@@ -120,10 +121,13 @@ func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[str
Version: receiver.ResourceVersion,
Provenance: ngmodels.ProvenanceNone,
}
storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations))
for _, integration := range receiver.Spec.Integrations {
config, err := ngmodels.IntegrationConfigFromType(integration.Type)
version := &integration.Version
if *version == "" {
version = nil
}
config, err := ngmodels.IntegrationConfigFromType(integration.Type, version)
if err != nil {
return nil, nil, err
}
+35 -10
View File
@@ -147,8 +147,9 @@ type Integration struct {
// IntegrationConfig represents the configuration of an integration. It contains the type and information about the fields.
type IntegrationConfig struct {
Type string
Fields map[string]IntegrationField
Type string
Version string
Fields map[string]IntegrationField
}
// IntegrationField represents a field in an integration configuration.
@@ -191,17 +192,40 @@ func (f IntegrationFieldPath) With(segment string) IntegrationFieldPath {
return newPath
}
// IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is
// not found an error is returned.
func IntegrationConfigFromType(integrationType string) (IntegrationConfig, error) {
// IntegrationConfigFromType returns an integration configuration for a given integration type of a given version.
// If version is nil, the current version of the integration is used.
// Returns an error if the integration type is not found or if the specified version does not exist.
//
// Parameters:
//
// integrationType - The type of integration to get configuration for
// version - Optional specific version to get configuration for, uses latest if nil
//
// Returns:
//
// IntegrationConfig - The integration configuration
// error - Error if integration type not found or invalid version specified
func IntegrationConfigFromType(integrationType string, version *string) (IntegrationConfig, error) {
config, err := channels_config.ConfigForIntegrationType(integrationType)
if err != nil {
return IntegrationConfig{}, err
}
integrationConfig := IntegrationConfig{Type: config.Type, Fields: make(map[string]IntegrationField, len(config.Options))}
for _, option := range config.Options {
var versionConfig channels_config.NotifierPluginVersion
if version == nil {
versionConfig = config.GetCurrentVersion()
} else {
var ok bool
versionConfig, ok = config.GetVersion(*version)
if !ok {
return IntegrationConfig{}, fmt.Errorf("version %s not found in config", *version)
}
}
integrationConfig := IntegrationConfig{
Type: config.Type,
Version: versionConfig.Version,
Fields: make(map[string]IntegrationField, len(versionConfig.Options)),
}
for _, option := range versionConfig.Options {
integrationConfig.Fields[option.PropertyName] = notifierOptionToIntegrationField(option)
}
return integrationConfig, nil
@@ -259,7 +283,8 @@ func traverseFields(flds map[string]IntegrationField, parentPath IntegrationFiel
func (config *IntegrationConfig) Clone() IntegrationConfig {
clone := IntegrationConfig{
Type: config.Type,
Type: config.Type,
Version: config.Version,
}
if len(config.Fields) > 0 {
+20 -7
View File
@@ -1,6 +1,7 @@
package models
import (
"maps"
"reflect"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/util"
)
func TestReceiver_Clone(t *testing.T) {
@@ -241,17 +243,19 @@ func TestIntegration_WithExistingSecureFields(t *testing.T) {
}
}
func TestIntegrationConfig(t *testing.T) {
func TestSecretsIntegrationConfig(t *testing.T) {
// Test that all known integration types have a config and correctly mark their secrets as secure.
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
config, err := IntegrationConfigFromType(integrationType)
config, err := IntegrationConfigFromType(integrationType, nil)
assert.NoError(t, err)
t.Run("v1 is current", func(t *testing.T) {
configv1, err := IntegrationConfigFromType(integrationType, util.Pointer("v1"))
assert.NoError(t, err)
assert.Equal(t, config, configv1)
})
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
assert.NoError(t, err)
allSecrets := make(map[string]struct{}, len(secrets))
@@ -271,9 +275,18 @@ func TestIntegrationConfig(t *testing.T) {
}
t.Run("Unknown type returns error", func(t *testing.T) {
_, err := IntegrationConfigFromType("__--**unknown_type**--__")
_, err := IntegrationConfigFromType("__--**unknown_type**--__", nil)
assert.Error(t, err)
})
t.Run("Unknown version returns error", func(t *testing.T) {
version := util.Pointer("__--**unknown_version**--__")
types := maps.Keys(alertingNotify.AllKnownConfigsForTesting)
for itype := range types {
_, err := IntegrationConfigFromType(itype, version)
assert.Errorf(t, err, "unknown version for integration type %s did not return error but should", itype)
}
})
}
func TestIntegration_SecureFields(t *testing.T) {
+5 -2
View File
@@ -1210,6 +1210,7 @@ func (n ReceiverMutators) WithProvenance(provenance Provenance) Mutator[Receiver
func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[Receiver] {
return func(r *Receiver) {
// TODO add support for v0
integration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
r.Integrations = []*Integration{&integration}
}
@@ -1217,6 +1218,7 @@ func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[R
func (n ReceiverMutators) WithInvalidIntegration(integrationType string) Mutator[Receiver] {
return func(r *Receiver) {
// TODO add support for v0
integration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
r.Integrations = []*Integration{&integration}
}
@@ -1305,8 +1307,9 @@ func (n IntegrationMutators) WithName(name string) Mutator[Integration] {
func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Integration] {
return func(c *Integration) {
// TODO add support for v0 integrations
config := alertingNotify.AllKnownConfigsForTesting[integrationType].GetRawNotifierConfig(c.Name)
integrationConfig, _ := IntegrationConfigFromType(integrationType)
integrationConfig, _ := IntegrationConfigFromType(integrationType, nil)
c.Config = integrationConfig
var settings map[string]any
@@ -1324,7 +1327,7 @@ func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Int
func (n IntegrationMutators) WithInvalidConfig(integrationType string) Mutator[Integration] {
return func(c *Integration) {
integrationConfig, _ := IntegrationConfigFromType(integrationType)
integrationConfig, _ := IntegrationConfigFromType(integrationType, nil)
c.Config = integrationConfig
c.Settings = map[string]interface{}{}
c.SecureSettings = map[string]string{}
@@ -2,6 +2,8 @@ package channels_config
import (
"fmt"
"iter"
"maps"
"os"
"strings"
@@ -2082,12 +2084,37 @@ func getSecretFields(parentPath string, options []NotifierOption) []string {
}
// ConfigForIntegrationType returns the config for the given integration type. Returns error is integration type is not known.
func ConfigForIntegrationType(contactPointType string) (*NotifierPlugin, error) {
notifiers := GetAvailableNotifiers()
for _, n := range notifiers {
func ConfigForIntegrationType(contactPointType string) (VersionedNotifierPlugin, error) {
notifiers := GetAvailableNotifiersV2()
for n := range notifiers {
if strings.EqualFold(n.Type, contactPointType) {
return n, nil
}
}
return nil, fmt.Errorf("unknown integration type '%s'", contactPointType)
return VersionedNotifierPlugin{}, fmt.Errorf("unknown integration type '%s'", contactPointType)
}
func GetAvailableNotifiersV2() iter.Seq[VersionedNotifierPlugin] {
v1 := GetAvailableNotifiers()
m := make(map[string]VersionedNotifierPlugin, len(v1))
for _, n := range v1 {
pl := VersionedNotifierPlugin{
Type: n.Type,
Name: n.Name,
Description: n.Description,
Heading: n.Heading,
Info: n.Info,
CurrentVersion: "v1",
Versions: []NotifierPluginVersion{
{
Version: "v1",
CanCreate: true,
Options: n.Options,
Info: "",
},
},
}
m[n.Type] = pl
}
return maps.Values(m)
}
@@ -10,6 +10,46 @@ type NotifierPlugin struct {
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 string `json:"currentVersion"`
Name string `json:"name"`
Heading string `json:"heading"`
Description string `json:"description"`
Info string `json:"info"`
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 string) (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 {
Version string `json:"version"`
CanCreate bool `json:"canCreate"`
Options []NotifierOption `json:"options"`
Info string `json:"info"`
}
// NotifierOption holds information about options specific for the NotifierPlugin.
type NotifierOption struct {
Element ElementType `json:"element"`
@@ -124,7 +124,7 @@ func PostableGrafanaReceiversToIntegrations(postables []*apimodels.PostableGrafa
}
func PostableGrafanaReceiverToIntegration(p *apimodels.PostableGrafanaReceiver) (*models.Integration, error) {
config, err := models.IntegrationConfigFromType(p.Type)
config, err := models.IntegrationConfigFromType(p.Type, nil)
if err != nil {
return nil, err
}
@@ -1522,14 +1522,15 @@ func persistInitialConfig(t *testing.T, amConfig definitions.PostableUserConfig)
func createIntegration(t *testing.T, integrationType string) v0alpha1.ReceiverIntegration {
cfg, ok := notify.AllKnownConfigsForTesting[integrationType]
require.Truef(t, ok, "no known config for integration type %s", integrationType)
return createIntegrationWithSettings(t, integrationType, cfg.Config)
return createIntegrationWithSettings(t, integrationType, "v1", cfg.Config)
}
func createIntegrationWithSettings(t *testing.T, integrationType string, settingsJson string) v0alpha1.ReceiverIntegration {
func createIntegrationWithSettings(t *testing.T, integrationType string, integrationVersion string, settingsJson string) v0alpha1.ReceiverIntegration {
settings := common.Unstructured{}
require.NoError(t, settings.UnmarshalJSON([]byte(settingsJson)))
return v0alpha1.ReceiverIntegration{
Settings: settings.Object,
Type: integrationType,
Version: integrationVersion,
DisableResolveMessage: util.Pointer(false),
}
}
@@ -3412,6 +3412,7 @@
"type": "object",
"required": [
"type",
"version",
"settings"
],
"properties": {
@@ -3437,6 +3438,10 @@
},
"uid": {
"type": "string"
},
"version": {
"type": "string",
"default": ""
}
}
},