CloudMigrations: Add support for migration of plugin resources (#95612)

* start plugins migration

* more plugin work

* add warning

* fakepluginsettings test

* tests get plugins

* lint

* load logos

* go lint

* get all plugins once

* locales

* josh suggestion to inject query in rtk

* more plugin filters

* remove datasource warning

* access control for plugins

* remove unused method

* lint

* use gcom list
pull/98518/head
Dana Axinte 6 months ago committed by GitHub
parent 6ca6ad4df7
commit 1699dfa307
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      go.mod
  2. 4
      go.sum
  3. 2
      pkg/services/cloudmigration/api/dtos.go
  4. 53
      pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration.go
  5. 279
      pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go
  6. 125
      pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go
  7. 2
      pkg/services/cloudmigration/model.go
  8. 4
      public/api-enterprise-spec.json
  9. 4
      public/api-merged.json
  10. 12
      public/app/features/migrate-to-cloud/MigrateToCloud.tsx
  11. 4
      public/app/features/migrate-to-cloud/api/endpoints.gen.ts
  12. 108
      public/app/features/migrate-to-cloud/api/index.ts
  13. 18
      public/app/features/migrate-to-cloud/onprem/NameCell.tsx
  14. 4
      public/app/features/migrate-to-cloud/onprem/Page.tsx
  15. 5
      public/app/features/migrate-to-cloud/onprem/ResourceDetailsModal.tsx
  16. 8
      public/app/features/migrate-to-cloud/onprem/ResourcesTable.test.tsx
  17. 33
      public/app/features/migrate-to-cloud/onprem/ResourcesTable.tsx
  18. 2
      public/app/features/migrate-to-cloud/onprem/TypeCell.tsx
  19. 2
      public/app/features/migrate-to-cloud/onprem/types.ts
  20. 2
      public/app/features/migrate-to-cloud/onprem/useNotifyOnSuccess.tsx
  21. 9
      public/locales/en-US/grafana.json
  22. 9
      public/locales/pseudo-LOCALE/grafana.json
  23. 4
      public/openapi3.json

@ -86,7 +86,7 @@ require (
github.com/grafana/grafana-app-sdk v0.23.1 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana-aws-sdk v0.31.5 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2 // @grafana/partner-datasources
github.com/grafana/grafana-cloud-migration-snapshot v1.3.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-cloud-migration-snapshot v1.6.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group
github.com/grafana/grafana-plugin-sdk-go v0.260.3 // @grafana/plugins-platform-backend

@ -2323,8 +2323,8 @@ github.com/grafana/grafana-aws-sdk v0.31.5 h1:4HpMQx7n4Qqoi7Bgu8KHQ2QKT9fYYdHilX
github.com/grafana/grafana-aws-sdk v0.31.5/go.mod h1:5p4Cjyr5ZiR6/RT2nFWkJ8XpIKgX4lAUmUMu70m2yCM=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2 h1:fV6IgVtViXcYZ4VqTAMuVBTLuGAnI27HhQkaLttzbPE=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.2/go.mod h1:Cbh94bfL5o6mUSaHFiOkx4r4CRKlo/DJLx4dPL8XrE0=
github.com/grafana/grafana-cloud-migration-snapshot v1.3.0 h1:F0O9eTy4jHjEd1Z3/qIza2GdY7PYpTddUeaq9p3NKGU=
github.com/grafana/grafana-cloud-migration-snapshot v1.3.0/go.mod h1:bd6Cm06EK0MzRO5ahUpbDz1SxNOKu+fzladbaRPHZPY=
github.com/grafana/grafana-cloud-migration-snapshot v1.6.0 h1:S4kHwr//AqhtL9xHBtz1gqVgZQeCRGTxjgsRBAkpjKY=
github.com/grafana/grafana-cloud-migration-snapshot v1.6.0/go.mod h1:rWNhyxYkgiXgV7xZ4yOQzMV08yikO8L8S8M5KNoQNpA=
github.com/grafana/grafana-google-sdk-go v0.1.0 h1:LKGY8z2DSxKjYfr2flZsWgTRTZ6HGQbTqewE3JvRaNA=
github.com/grafana/grafana-google-sdk-go v0.1.0/go.mod h1:Vo2TKWfDVmNTELBUM+3lkrZvFtBws0qSZdXhQxRdJrE=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs=

@ -131,6 +131,7 @@ const (
NotificationPolicyType MigrateDataType = "NOTIFICATION_POLICY"
NotificationTemplateType MigrateDataType = "NOTIFICATION_TEMPLATE"
MuteTimingType MigrateDataType = "MUTE_TIMING"
PluginDataType MigrateDataType = "PLUGIN"
)
// swagger:enum ItemStatus
@ -158,7 +159,6 @@ const (
ErrResourceConflict ItemErrorCode = "RESOURCE_CONFLICT"
ErrUnexpectedStatus ItemErrorCode = "UNEXPECTED_STATUS_CODE"
ErrInternalServiceError ItemErrorCode = "INTERNAL_SERVICE_ERROR"
ErrOnlyCoreDataSources ItemErrorCode = "ONLY_CORE_DATA_SOURCES"
ErrGeneric ItemErrorCode = "GENERIC_ERROR"
)

@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authapi"
"github.com/grafana/grafana/pkg/services/authapi/fake"
"github.com/grafana/grafana/pkg/services/cloudmigration"
@ -32,6 +33,7 @@ import (
"github.com/grafana/grafana/pkg/services/gcom"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/secrets"
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
@ -68,6 +70,8 @@ type Service struct {
dashboardService dashboards.DashboardService
folderService folder.Service
pluginStore pluginstore.Store
accessControl accesscontrol.AccessControl
pluginSettingsService pluginsettings.Service
secretsService secrets.Service
kvStore *kvstore.NamespacedKVStore
libraryElementsService libraryelements.Service
@ -105,6 +109,8 @@ func ProvideService(
dashboardService dashboards.DashboardService,
folderService folder.Service,
pluginStore pluginstore.Store,
pluginSettingsService pluginsettings.Service,
accessControl accesscontrol.AccessControl,
kvStore kvstore.KVStore,
libraryElementsService libraryelements.Service,
ngAlert *ngalert.AlertNG,
@ -125,6 +131,8 @@ func ProvideService(
dashboardService: dashboardService,
folderService: folderService,
pluginStore: pluginStore,
pluginSettingsService: pluginSettingsService,
accessControl: accessControl,
kvStore: kvstore.WithNamespace(kvStore, 0, "cloudmigration"),
libraryElementsService: libraryElementsService,
ngAlert: ngAlert,
@ -588,13 +596,7 @@ func (s *Service) GetSnapshot(ctx context.Context, query cloudmigration.GetSnaps
s.log.Error("unexpected GMS snapshot state: %s", snapshotMeta.State)
return snapshot, nil
}
// For 11.2 we only support core data sources. Apply a warning for any non-core ones before storing.
resources, err := s.getResourcesWithPluginWarnings(ctx, snapshotMeta.Results)
if err != nil {
// treat this as non-fatal since the migration still succeeded
s.log.Error("error applying plugin warnings, please open a bug report: %w", err)
}
resources := snapshotMeta.Results
// Log the errors for resources with errors at migration
for _, resource := range resources {
@ -901,40 +903,3 @@ func (s *Service) deleteLocalFiles(snapshots []cloudmigration.CloudMigrationSnap
}
return err
}
// getResourcesWithPluginWarnings iterates through each resource and, if a non-core datasource, applies a warning that we only support core
func (s *Service) getResourcesWithPluginWarnings(ctx context.Context, results []cloudmigration.CloudMigrationResource) ([]cloudmigration.CloudMigrationResource, error) {
dsList, err := s.dsService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{})
if err != nil {
return nil, fmt.Errorf("getting all data sources: %w", err)
}
dsMap := make(map[string]*datasources.DataSource, len(dsList))
for i := 0; i < len(dsList); i++ {
dsMap[dsList[i].UID] = dsList[i]
}
for i := 0; i < len(results); i++ {
r := results[i]
if r.Type == cloudmigration.DatasourceDataType &&
r.Error == "" { // any error returned by GMS takes priority
ds, ok := dsMap[r.RefID]
if !ok {
s.log.Error("data source with id %s was not found in data sources list", r.RefID)
continue
}
p, found := s.pluginStore.Plugin(ctx, ds.Type)
// if the plugin is not found, it means it was uninstalled, meaning it wasn't core
if !p.IsCorePlugin() || !found {
r.Status = cloudmigration.ItemStatusWarning
r.ErrorCode = cloudmigration.ErrOnlyCoreDataSources
r.Error = "Only core data sources are supported. Please ensure the plugin is installed on the cloud stack."
}
results[i] = r
}
}
return results, nil
}

@ -24,8 +24,6 @@ import (
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
"github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/services/cloudmigration/gmsclient"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
datafakes "github.com/grafana/grafana/pkg/services/datasources/fakes"
@ -39,6 +37,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/models"
ngalertstore "github.com/grafana/grafana/pkg/services/ngalert/store"
ngalertfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
secretsfakes "github.com/grafana/grafana/pkg/services/secrets/fakes"
@ -447,146 +446,6 @@ func Test_SortFolders(t *testing.T) {
require.Equal(t, expected, sortedFolders)
}
func Test_NonCoreDataSourcesHaveWarning(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
// Insert a processing snapshot into the database before we start so we query GMS
createTokenResp, err := s.CreateToken(context.Background())
assert.NoError(t, err)
assert.NotEmpty(t, createTokenResp.Token)
sess, err := s.store.CreateMigrationSession(context.Background(), cloudmigration.CloudMigrationSession{
AuthToken: createTokenResp.Token,
})
require.NoError(t, err)
snapshotUid, err := s.store.CreateSnapshot(context.Background(), cloudmigration.CloudMigrationSnapshot{
UID: uuid.NewString(),
SessionUID: sess.UID,
Status: cloudmigration.SnapshotStatusProcessing,
GMSSnapshotUID: "gms uid",
})
require.NoError(t, err)
// GMS should return: a core ds, a non-core ds, a non-core ds with an error, and a ds that has been uninstalled
gmsClientMock := &gmsClientMock{
getSnapshotResponse: &cloudmigration.GetSnapshotStatusResponse{
State: cloudmigration.SnapshotStateFinished,
Results: []cloudmigration.CloudMigrationResource{
{
Name: "1 name",
ParentName: "1 parent name",
Type: cloudmigration.DatasourceDataType,
RefID: "1", // this will be core
Status: cloudmigration.ItemStatusOK,
SnapshotUID: snapshotUid,
},
{
Name: "2 name",
ParentName: "",
Type: cloudmigration.DatasourceDataType,
RefID: "2", // this will be non-core
Status: cloudmigration.ItemStatusOK,
SnapshotUID: snapshotUid,
},
{
Name: "3 name",
ParentName: "3 parent name",
Type: cloudmigration.DatasourceDataType,
RefID: "3", // this will be non-core with an error
Status: cloudmigration.ItemStatusError,
Error: "please don't overwrite me",
SnapshotUID: snapshotUid,
},
{
Name: "4 name",
ParentName: "4 folder name",
Type: cloudmigration.DatasourceDataType,
RefID: "4", // this will be deleted
Status: cloudmigration.ItemStatusOK,
SnapshotUID: snapshotUid,
},
},
},
}
s.gmsClient = gmsClientMock
// Update the internal plugin store and ds store with seed data matching the descriptions above
s.pluginStore = pluginstore.NewFakePluginStore([]pluginstore.Plugin{
{
JSONData: plugins.JSONData{
ID: "1",
},
Class: plugins.ClassCore,
},
{
JSONData: plugins.JSONData{
ID: "2",
},
Class: plugins.ClassExternal,
},
{
JSONData: plugins.JSONData{
ID: "3",
},
Class: plugins.ClassExternal,
},
}...)
s.dsService = &datafakes.FakeDataSourceService{
DataSources: []*datasources.DataSource{
{UID: "1", Type: "1"},
{UID: "2", Type: "2"},
{UID: "3", Type: "3"},
{UID: "4", Type: "4"},
},
}
var snapshot *cloudmigration.CloudMigrationSnapshot
hasFourResources := func() bool {
// Retrieve the snapshot with results
var err error
snapshot, err = s.GetSnapshot(ctxWithSignedInUser(), cloudmigration.GetSnapshotsQuery{
SnapshotUID: snapshotUid,
SessionUID: sess.UID,
ResultPage: 1,
ResultLimit: 10,
})
if !assert.NoError(t, err) {
return false
}
return len(snapshot.Resources) == 4
}
require.Eventually(t, hasFourResources, time.Second, 10*time.Millisecond)
findRef := func(id string) *cloudmigration.CloudMigrationResource {
for _, r := range snapshot.Resources {
if r.RefID == id {
return &r
}
}
return nil
}
shouldBeUnaltered := findRef("1")
assert.Equal(t, cloudmigration.ItemStatusOK, shouldBeUnaltered.Status)
assert.Empty(t, shouldBeUnaltered.Error)
shouldBeAltered := findRef("2")
assert.Equal(t, cloudmigration.ItemStatusWarning, shouldBeAltered.Status)
assert.Equal(t, shouldBeAltered.Error, "Only core data sources are supported. Please ensure the plugin is installed on the cloud stack.")
shouldHaveOriginalError := findRef("3")
assert.Equal(t, cloudmigration.ItemStatusError, shouldHaveOriginalError.Status)
assert.Equal(t, shouldHaveOriginalError.Error, "please don't overwrite me")
uninstalledAltered := findRef("4")
assert.Equal(t, cloudmigration.ItemStatusWarning, uninstalledAltered.Status)
assert.Equal(t, uninstalledAltered.Error, "Only core data sources are supported. Please ensure the plugin is installed on the cloud stack.")
}
func TestDeleteSession(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
user := &user.SignedInUser{UserUID: "user123"}
@ -817,13 +676,135 @@ func TestGetLibraryElementsCommands(t *testing.T) {
require.Equal(t, createLibraryElementCmd.UID, cmds[0].UID)
}
func ctxWithSignedInUser() context.Context {
c := &contextmodel.ReqContext{
SignedInUser: &user.SignedInUser{OrgID: 1},
// NOTE: this should be on the plugin object
func TestIsPublicSignatureType(t *testing.T) {
testcases := []struct {
signature plugins.SignatureType
expectedPublic bool
}{
{
signature: plugins.SignatureTypeCommunity,
expectedPublic: true,
},
{
signature: plugins.SignatureTypeCommercial,
expectedPublic: true,
},
{
signature: plugins.SignatureTypeGrafana,
expectedPublic: true,
},
{
signature: plugins.SignatureTypePrivate,
expectedPublic: false,
},
{
signature: plugins.SignatureTypePrivateGlob,
expectedPublic: false,
},
}
for _, testcase := range testcases {
resPublic := IsPublicSignatureType(testcase.signature)
require.Equal(t, resPublic, testcase.expectedPublic)
}
}
func TestGetPlugins(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
user := &user.SignedInUser{OrgID: 1}
s.pluginStore = pluginstore.NewFakePluginStore([]pluginstore.Plugin{
{
JSONData: plugins.JSONData{
ID: "plugin-core",
Type: plugins.TypeDataSource,
},
Class: plugins.ClassCore,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
},
{
JSONData: plugins.JSONData{
ID: "plugin-external-valid-grafana",
Type: plugins.TypeDataSource,
AutoEnabled: false,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana,
},
{
JSONData: plugins.JSONData{
ID: "plugin-external-valid-commercial",
Type: plugins.TypePanel,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeCommercial,
},
{
JSONData: plugins.JSONData{
ID: "plugin-external-valid-community",
Type: plugins.TypePanel,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeCommunity,
},
{
JSONData: plugins.JSONData{
ID: "plugin-external-invalid",
Type: plugins.TypePanel,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusInvalid,
SignatureType: plugins.SignatureTypeGrafana,
},
{
JSONData: plugins.JSONData{
ID: "plugin-external-unsigned",
Type: plugins.TypePanel,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
SignatureType: plugins.SignatureTypeGrafana,
},
{
JSONData: plugins.JSONData{
ID: "plugin-external-valid-private",
Type: plugins.TypeApp,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusUnsigned,
SignatureType: plugins.SignatureTypePrivate,
},
}...)
s.pluginSettingsService = &pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{
"plugin-external-valid-grafana": {ID: 0, OrgID: user.OrgID, PluginID: "plugin-external-valid-grafana", PluginVersion: "1.0.0", Enabled: true},
}}
plugins, err := s.getPlugins(ctx, user)
require.NoError(t, err)
require.NotNil(t, plugins)
require.Len(t, plugins, 3)
expectedPluginIDs := []string{"plugin-external-valid-grafana", "plugin-external-valid-commercial", "plugin-external-valid-community"}
pluginsIDs := make([]string, 0)
for _, plugin := range plugins {
// Special case of using the settings from the settings store
if plugin.ID == "plugin-external-valid-grafana" {
require.True(t, plugin.SettingCmd.Enabled)
}
pluginsIDs = append(pluginsIDs, plugin.ID)
}
k := ctxkey.Key{}
ctx := context.WithValue(context.Background(), k, c)
return ctx
require.ElementsMatch(t, pluginsIDs, expectedPluginIDs)
}
type configOverrides func(c *setting.Cfg)
@ -949,6 +930,8 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...conf
dashboardService,
mockFolder,
&pluginstore.FakePluginStore{},
&pluginsettings.FakePluginSettings{},
actest.FakeAccessControl{ExpectedEvaluate: true},
kvstore.ProvideService(sqlStore),
&libraryelementsfake.LibraryElementService{},
ng,

@ -4,6 +4,7 @@ import (
"context"
cryptoRand "crypto/rand"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@ -14,12 +15,18 @@ import (
snapshot "github.com/grafana/grafana-cloud-migration-snapshot/src"
"github.com/grafana/grafana-cloud-migration-snapshot/src/contracts"
"github.com/grafana/grafana-cloud-migration-snapshot/src/infra/crypto"
"github.com/grafana/grafana/pkg/infra/tracing"
plugins "github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
libraryelements "github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util/retryer"
"golang.org/x/crypto/nacl/box"
@ -37,12 +44,20 @@ var currentMigrationTypes = []cloudmigration.MigrateDataType{
cloudmigration.ContactPointType,
cloudmigration.NotificationPolicyType,
cloudmigration.AlertRuleType,
cloudmigration.PluginDataType,
}
func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser) (*cloudmigration.MigrateDataRequest, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getMigrationDataJSON")
defer span.End()
// Plugins
plugins, err := s.getPlugins(ctx, signedInUser)
if err != nil {
s.log.Error("Failed to get plugins", "err", err)
return nil, err
}
// Data sources
dataSources, err := s.getDataSourceCommands(ctx, signedInUser)
if err != nil {
@ -100,10 +115,19 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
migrationDataSlice := make(
[]cloudmigration.MigrateDataRequestItem, 0,
len(dataSources)+len(dashs)+len(folders)+len(libraryElements)+
len(plugins)+len(dataSources)+len(dashs)+len(folders)+len(libraryElements)+
len(muteTimings)+len(notificationTemplates)+len(contactPoints)+len(alertRules),
)
for _, plugin := range plugins {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.PluginDataType,
RefID: plugin.ID,
Name: plugin.Name,
Data: plugin.SettingCmd,
})
}
for _, ds := range dataSources {
migrationDataSlice = append(migrationDataSlice, cloudmigration.MigrateDataRequestItem{
Type: cloudmigration.DatasourceDataType,
@ -356,6 +380,105 @@ func (s *Service) getLibraryElementsCommands(ctx context.Context, signedInUser *
return cmds, nil
}
type PluginCmd struct {
ID string `json:"id"`
Name string `json:"name"`
SettingCmd pluginsettings.UpdatePluginSettingCmd `json:"settingCmd"`
}
// IsPublicSignatureType returns true if plugin signature type is public
func IsPublicSignatureType(signatureType plugins.SignatureType) bool {
switch signatureType {
case plugins.SignatureTypeGrafana, plugins.SignatureTypeCommercial, plugins.SignatureTypeCommunity:
return true
case plugins.SignatureTypePrivate, plugins.SignatureTypePrivateGlob:
return false
}
return false
}
// getPlugins returns the json payloads required by the plugin creation API
func (s *Service) getPlugins(ctx context.Context, signedInUser *user.SignedInUser) ([]PluginCmd, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getPlugins")
defer span.End()
results := make([]PluginCmd, 0)
plugins := s.pluginStore.Plugins(ctx)
// Obtain plugins from gcom
requestID := tracing.TraceIDFromContext(ctx, false)
gcomPlugins, err := s.gcomService.GetPlugins(ctx, requestID)
if err != nil {
return results, fmt.Errorf("fetching gcom plugins: %w", err)
}
// Permissions for listing plugins, taken from plugins api
userIsOrgAdmin := signedInUser.HasRole(org.RoleAdmin)
hasAccess, _ := s.accessControl.Evaluate(ctx, signedInUser, ac.EvalAny(
ac.EvalPermission(datasources.ActionCreate),
ac.EvalPermission(pluginaccesscontrol.ActionInstall),
))
if !(userIsOrgAdmin || hasAccess) {
s.log.Info("user is not allowed to list non-core plugins", "UID", signedInUser.UserUID)
return results, nil
}
for _, plugin := range plugins {
// filter plugins to keep only the ones allowed by gcom
if _, exists := gcomPlugins[plugin.ID]; !exists {
continue
}
// filter plugins to keep only non core, signed, with public signature type plugins
if plugin.IsCorePlugin() || !plugin.Signature.IsValid() || !IsPublicSignatureType(plugin.SignatureType) {
continue
}
// filter out dependent app plugins
if plugin.IncludedInAppID != "" {
continue
}
// Permissions filtering, taken from plugins api
hasAccess, _ = s.accessControl.Evaluate(ctx, signedInUser, ac.EvalPermission(pluginaccesscontrol.ActionWrite, pluginaccesscontrol.ScopeProvider.GetResourceScope(plugin.ID)))
if !hasAccess {
continue
}
pluginSettingCmd := pluginsettings.UpdatePluginSettingCmd{
Enabled: plugin.JSONData.AutoEnabled,
Pinned: plugin.Pinned,
PluginVersion: plugin.Info.Version,
PluginId: plugin.ID,
}
// get plugin settings from db if they exist
ps, err := s.pluginSettingsService.GetPluginSettingByPluginID(ctx, &pluginsettings.GetByPluginIDArgs{
PluginID: plugin.ID,
OrgID: signedInUser.OrgID,
})
if err != nil && !errors.Is(err, pluginsettings.ErrPluginSettingNotFound) {
return nil, fmt.Errorf("failed to get plugin settings: %w", err)
} else if ps != nil {
pluginSettingCmd.Enabled = ps.Enabled
pluginSettingCmd.Pinned = ps.Pinned
pluginSettingCmd.JsonData = ps.JSONData
decryptedData, err := s.secretsService.DecryptJsonData(ctx, ps.SecureJSONData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt secure json data: %w", err)
}
pluginSettingCmd.SecureJsonData = decryptedData
}
results = append(results, PluginCmd{
ID: plugin.ID,
Name: plugin.Name,
SettingCmd: pluginSettingCmd,
})
}
return results, nil
}
// asynchronous process for writing the snapshot to the filesystem and updating the snapshot status
func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedInUser, maxItemsPerPartition uint32, metadata []byte, snapshotMeta cloudmigration.CloudMigrationSnapshot) error {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.buildSnapshot")

@ -92,6 +92,7 @@ const (
NotificationPolicyType MigrateDataType = "NOTIFICATION_POLICY"
NotificationTemplateType MigrateDataType = "NOTIFICATION_TEMPLATE"
MuteTimingType MigrateDataType = "MUTE_TIMING"
PluginDataType MigrateDataType = "PLUGIN"
)
type ItemStatus string
@ -116,7 +117,6 @@ const (
ErrResourceConflict ResourceErrorCode = "RESOURCE_CONFLICT"
ErrUnexpectedStatus ResourceErrorCode = "UNEXPECTED_STATUS_CODE"
ErrInternalServiceError ResourceErrorCode = "INTERNAL_SERVICE_ERROR"
ErrOnlyCoreDataSources ResourceErrorCode = "ONLY_CORE_DATA_SOURCES"
ErrGeneric ResourceErrorCode = "GENERIC_ERROR"
)

@ -5637,7 +5637,6 @@
"RESOURCE_CONFLICT",
"UNEXPECTED_STATUS_CODE",
"INTERNAL_SERVICE_ERROR",
"ONLY_CORE_DATA_SOURCES",
"GENERIC_ERROR"
]
},
@ -5674,7 +5673,8 @@
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING"
"MUTE_TIMING",
"PLUGIN"
]
}
}

@ -17137,7 +17137,6 @@
"RESOURCE_CONFLICT",
"UNEXPECTED_STATUS_CODE",
"INTERNAL_SERVICE_ERROR",
"ONLY_CORE_DATA_SOURCES",
"GENERIC_ERROR"
]
},
@ -17174,7 +17173,8 @@
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING"
"MUTE_TIMING",
"PLUGIN"
]
}
}

@ -34,6 +34,18 @@ export default function MigrateToCloud() {
to learn more about this feature!
</Trans>
</Alert>
<Alert
title={t('migrate-to-cloud.public-preview.title-plugins', 'Migration of plugins')}
buttonContent={''}
severity={'info'}
>
<Trans i18nKey="migrate-to-cloud.public-preview.message-plugins">
Only Community and Commercial signed plugins are eligible for migration. Their latest version will be
installed in the cloud instance, please upgrade your plugins before starting the migration process.
</Trans>
</Alert>
{config.cloudMigrationIsTarget ? <CloudPage /> : <OnPremPage />}
</Page>
);

@ -182,7 +182,6 @@ export type MigrateDataResponseItemDto = {
| 'RESOURCE_CONFLICT'
| 'UNEXPECTED_STATUS_CODE'
| 'INTERNAL_SERVICE_ERROR'
| 'ONLY_CORE_DATA_SOURCES'
| 'GENERIC_ERROR';
message?: string;
name?: string;
@ -198,7 +197,8 @@ export type MigrateDataResponseItemDto = {
| 'CONTACT_POINT'
| 'NOTIFICATION_POLICY'
| 'NOTIFICATION_TEMPLATE'
| 'MUTE_TIMING';
| 'MUTE_TIMING'
| 'PLUGIN';
};
export type SnapshotResourceStats = {
statuses?: {

@ -1,55 +1,75 @@
export * from './endpoints.gen';
import { BaseQueryFn, EndpointDefinition } from '@reduxjs/toolkit/query';
import { getLocalPlugins } from 'app/features/plugins/admin/api';
import { LocalPlugin } from 'app/features/plugins/admin/types';
import { generatedAPI } from './endpoints.gen';
export const cloudMigrationAPI = generatedAPI.enhanceEndpoints({
addTagTypes: ['cloud-migration-token', 'cloud-migration-session', 'cloud-migration-snapshot'],
export const cloudMigrationAPI = generatedAPI
.injectEndpoints({
endpoints: (build) => ({
// Manually written because the Swagger specifications for the plugins endpoint do not exist
getLocalPluginList: build.query<LocalPlugin[], void>({
queryFn: async () => {
try {
const list = await getLocalPlugins();
return { data: list };
} catch (error) {
return { error: error };
}
},
}),
}),
})
.enhanceEndpoints({
addTagTypes: ['cloud-migration-token', 'cloud-migration-session', 'cloud-migration-snapshot'],
endpoints: {
// Cloud-side - create token
getCloudMigrationToken: {
providesTags: ['cloud-migration-token'],
},
createCloudMigrationToken: {
invalidatesTags: ['cloud-migration-token'],
},
deleteCloudMigrationToken: {
invalidatesTags: ['cloud-migration-token'],
},
endpoints: {
// Cloud-side - create token
getCloudMigrationToken: {
providesTags: ['cloud-migration-token'],
},
createCloudMigrationToken: {
invalidatesTags: ['cloud-migration-token'],
},
deleteCloudMigrationToken: {
invalidatesTags: ['cloud-migration-token'],
},
// On-prem session management (entering token)
getSessionList: {
providesTags: ['cloud-migration-session'] /* should this be a -list? */,
},
getSession: {
providesTags: ['cloud-migration-session'],
},
createSession: {
invalidatesTags: ['cloud-migration-session'],
},
deleteSession: {
invalidatesTags: ['cloud-migration-session', 'cloud-migration-snapshot'],
},
// On-prem session management (entering token)
getSessionList: {
providesTags: ['cloud-migration-session'] /* should this be a -list? */,
},
getSession: {
providesTags: ['cloud-migration-session'],
},
createSession: {
invalidatesTags: ['cloud-migration-session'],
},
deleteSession: {
invalidatesTags: ['cloud-migration-session', 'cloud-migration-snapshot'],
},
// Snapshot management
getShapshotList: {
providesTags: ['cloud-migration-snapshot'],
},
getSnapshot: {
providesTags: ['cloud-migration-snapshot'],
},
createSnapshot: {
invalidatesTags: ['cloud-migration-snapshot'],
},
uploadSnapshot: {
invalidatesTags: ['cloud-migration-snapshot'],
},
// Snapshot management
getShapshotList: {
providesTags: ['cloud-migration-snapshot'],
},
getSnapshot: {
providesTags: ['cloud-migration-snapshot'],
},
createSnapshot: {
invalidatesTags: ['cloud-migration-snapshot'],
},
uploadSnapshot: {
invalidatesTags: ['cloud-migration-snapshot'],
},
getDashboardByUid: suppressErrorsOnQuery,
getLibraryElementByUid: suppressErrorsOnQuery,
},
});
getDashboardByUid: suppressErrorsOnQuery,
getLibraryElementByUid: suppressErrorsOnQuery,
getLocalPluginList: suppressErrorsOnQuery,
},
});
function suppressErrorsOnQuery<QueryArg, BaseQuery extends BaseQueryFn, TagTypes extends string, ResultType>(
endpoint: EndpointDefinition<QueryArg, BaseQuery, TagTypes, ResultType>
@ -65,3 +85,5 @@ function suppressErrorsOnQuery<QueryArg, BaseQuery extends BaseQueryFn, TagTypes
return baseQuery;
};
}
export const { useGetLocalPluginListQuery } = cloudMigrationAPI;

@ -9,6 +9,7 @@ import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Trans } from 'app/core/internationalization';
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { LocalPlugin } from '../../plugins/admin/types';
import { useGetDashboardByUidQuery, useGetLibraryElementByUidQuery } from '../api';
import { ResourceTableItem } from './types';
@ -205,6 +206,7 @@ function BasicResourceInfo({ data }: { data: ResourceTableItem }) {
function ResourceIcon({ resource }: { resource: ResourceTableItem }) {
const styles = useStyles2(getIconStyles);
const datasource = useDatasource(resource.type === 'DATASOURCE' ? resource.refId : undefined);
const pluginLogo = usePluginLogo(resource.type === 'PLUGIN' ? resource.plugin : undefined);
switch (resource.type) {
case 'DASHBOARD':
@ -229,6 +231,11 @@ function ResourceIcon({ resource }: { resource: ResourceTableItem }) {
return <Icon size="xl" name="bell" />;
case 'ALERT_RULE':
return <Icon size="xl" name="bell" />;
case 'PLUGIN':
if (pluginLogo) {
return <img className={styles.icon} src={pluginLogo} alt="" />;
}
return <Icon size="xl" name="plug" />;
default:
return undefined;
}
@ -257,3 +264,14 @@ function useDatasource(datasourceUID: string | undefined): DataSourceInstanceSet
return datasource;
}
function usePluginLogo(plugin: LocalPlugin | undefined): string | undefined {
const logos = useMemo(() => {
if (!plugin) {
return undefined;
}
return plugin?.info?.logos;
}, [plugin]);
return logos?.small;
}

@ -15,6 +15,7 @@ import {
useGetShapshotListQuery,
useGetSnapshotQuery,
useUploadSnapshotMutation,
useGetLocalPluginListQuery,
} from '../api';
import { AlertWithTraceID } from '../shared/AlertWithTraceID';
@ -122,6 +123,8 @@ export const Page = () => {
const [performCancelSnapshot, cancelSnapshotResult] = useCancelSnapshotMutation();
const [performDisconnect, disconnectResult] = useDeleteSessionMutation();
const { currentData: localPlugins = [] } = useGetLocalPluginListQuery();
useNotifySuccessful(snapshot.data);
const sessionUid = session.data?.uid;
@ -240,6 +243,7 @@ export const Page = () => {
<Stack gap={4} direction="column">
<ResourcesTable
resources={snapshot.data.results}
localPlugins={localPlugins}
onChangePage={setPage}
numberOfPages={Math.ceil((snapshot?.data?.stats?.total || 0) / PAGE_SIZE)}
page={page}

@ -53,11 +53,6 @@ function getTMessage(errorCode: MigrateDataResponseItemDto['errorCode']): string
'migrate-to-cloud.resource-details.error-messages.resource-conflict',
'There is a resource conflict with the target instance. Please check the Grafana server logs for more details.'
);
case 'ONLY_CORE_DATA_SOURCES':
return t(
'migrate-to-cloud.resource-details.error-messages.only-core-data-sources',
'Only core data sources are supported. Please ensure the plugin is installed on the cloud stack.'
);
case 'UNEXPECTED_STATUS_CODE':
return t(
'migrate-to-cloud.resource-details.error-messages.unexpected-error',

@ -19,7 +19,13 @@ setBackendSrv(backendSrv);
function render(props: Partial<ResourcesTableProps>) {
rtlRender(
<TestProvider>
<ResourcesTable onChangePage={() => {}} numberOfPages={10} page={0} resources={props.resources || []} />
<ResourcesTable
onChangePage={() => {}}
numberOfPages={10}
page={0}
resources={props.resources || []}
localPlugins={[]}
/>
</TestProvider>
);
}

@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import { InteractiveTable, Pagination, Stack } from '@grafana/ui';
import { LocalPlugin } from '../../plugins/admin/types';
import { MigrateDataResponseItemDto } from '../api';
import { NameCell } from './NameCell';
@ -12,6 +13,7 @@ import { ResourceTableItem } from './types';
export interface ResourcesTableProps {
resources: MigrateDataResponseItemDto[];
localPlugins: LocalPlugin[];
page: number;
numberOfPages: number;
onChangePage: (page: number) => void;
@ -23,7 +25,13 @@ const columns = [
{ id: 'status', header: 'Status', cell: StatusCell },
];
export function ResourcesTable({ resources, numberOfPages = 0, onChangePage, page = 1 }: ResourcesTableProps) {
export function ResourcesTable({
resources,
localPlugins,
numberOfPages = 0,
onChangePage,
page = 1,
}: ResourcesTableProps) {
const [focusedResource, setfocusedResource] = useState<ResourceTableItem | undefined>();
const handleShowDetailsModal = useCallback((resource: ResourceTableItem) => {
@ -31,8 +39,16 @@ export function ResourcesTable({ resources, numberOfPages = 0, onChangePage, pag
}, []);
const data = useMemo(() => {
return resources.map((r) => ({ ...r, showDetails: handleShowDetailsModal }));
}, [resources, handleShowDetailsModal]);
return resources.map((r) => {
const plugin = getPlugin(r, localPlugins);
return {
...r,
showDetails: handleShowDetailsModal,
plugin: plugin,
};
});
}, [resources, handleShowDetailsModal, localPlugins]);
return (
<>
@ -46,3 +62,14 @@ export function ResourcesTable({ resources, numberOfPages = 0, onChangePage, pag
</>
);
}
function getPlugin(
r: MigrateDataResponseItemDto | undefined,
plugins: LocalPlugin[] | undefined
): LocalPlugin | undefined {
if (!r || !plugins || r.type !== 'PLUGIN') {
return undefined;
}
return plugins.find((plugin) => plugin.id === r.refId);
}

@ -23,6 +23,8 @@ export function prettyTypeName(type: ResourceTableItem['type']) {
return t('migrate-to-cloud.resource-type.notification_policy', 'Notification Policy');
case 'ALERT_RULE':
return t('migrate-to-cloud.resource-type.alert_rule', 'Alert Rule');
case 'PLUGIN':
return t('migrate-to-cloud.resource-type.plugin', 'Plugin');
default:
return t('migrate-to-cloud.resource-type.unknown', 'Unknown');
}

@ -1,5 +1,7 @@
import { LocalPlugin } from '../../plugins/admin/types';
import { MigrateDataResponseItemDto } from '../api';
export interface ResourceTableItem extends MigrateDataResponseItemDto {
showDetails: (resource: ResourceTableItem) => void;
plugin: LocalPlugin | undefined;
}

@ -62,6 +62,8 @@ function getTranslatedMessage(snapshot: GetSnapshotResponseDto) {
types.push(t('migrate-to-cloud.migrated-counts.notification_policies', 'notification policies'));
} else if (type === 'ALERT_RULE') {
types.push(t('migrate-to-cloud.migrated-counts.alert_rules', 'alert rules'));
} else if (type === 'PLUGIN') {
types.push(t('migrate-to-cloud.migrated-counts.plugins', 'plugins'));
}
distinctItems += 1;

@ -1873,7 +1873,8 @@
"library_elements": "library elements",
"mute_timings": "mute timings",
"notification_policies": "notification policies",
"notification_templates": "notification templates"
"notification_templates": "notification templates",
"plugins": "plugins"
},
"migration-token": {
"delete-button": "Delete token",
@ -1924,7 +1925,9 @@
"public-preview": {
"button-text": "Give feedback",
"message": "No SLAs are available yet. <2>Visit our docs</2> to learn more about this feature!",
"title": "Migrate to Grafana Cloud is in public preview"
"message-plugins": "Only Community and Commercial signed plugins are eligible for migration. Their latest version will be installed in the cloud instance, please upgrade your plugins before starting the migration process.",
"title": "Migrate to Grafana Cloud is in public preview",
"title-plugins": "Migration of plugins"
},
"resource-details": {
"dismiss-button": "OK",
@ -1937,7 +1940,6 @@
"generic-error": "There has been an error while migrating. Please check the cloud migration logs for more information.",
"internal-service-error": "There has been an error while migrating. Please check the Grafana server logs for more details.",
"library-element-name-conflict": "There is a library element with the same name in the target instance. Rename one of them and try again.",
"only-core-data-sources": "Only core data sources are supported. Please ensure the plugin is installed on the cloud stack.",
"resource-conflict": "There is a resource conflict with the target instance. Please check the Grafana server logs for more details.",
"unexpected-error": "There has been an error while migrating. Please check the Grafana server logs for more details.",
"unsupported-data-type": "Migration of this data type is not currently supported."
@ -1976,6 +1978,7 @@
"mute_timing": "Mute Timing",
"notification_policy": "Notification Policy",
"notification_template": "Notification Template",
"plugin": "Plugin",
"unknown": "Unknown"
},
"summary": {

@ -1873,7 +1873,8 @@
"library_elements": "ľįþřäřy ęľęmęʼnŧş",
"mute_timings": "mūŧę ŧįmįʼnģş",
"notification_policies": "ʼnőŧįƒįčäŧįőʼn pőľįčįęş",
"notification_templates": "ʼnőŧįƒįčäŧįőʼn ŧęmpľäŧęş"
"notification_templates": "ʼnőŧįƒįčäŧįőʼn ŧęmpľäŧęş",
"plugins": "pľūģįʼnş"
},
"migration-token": {
"delete-button": "Đęľęŧę ŧőĸęʼn",
@ -1924,7 +1925,9 @@
"public-preview": {
"button-text": "Ğįvę ƒęęđþäčĸ",
"message": "Ńő ŜĿÅş äřę äväįľäþľę yęŧ. <2>Vįşįŧ őūř đőčş</2> ŧő ľęäřʼn mőřę äþőūŧ ŧĥįş ƒęäŧūřę!",
"title": "Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ įş įʼn pūþľįč přęvįęŵ"
"message-plugins": "Øʼnľy Cőmmūʼnįŧy äʼnđ Cőmmęřčįäľ şįģʼnęđ pľūģįʼnş äřę ęľįģįþľę ƒőř mįģřäŧįőʼn. Ŧĥęįř ľäŧęşŧ vęřşįőʼn ŵįľľ þę įʼnşŧäľľęđ įʼn ŧĥę čľőūđ įʼnşŧäʼnčę, pľęäşę ūpģřäđę yőūř pľūģįʼnş þęƒőřę şŧäřŧįʼnģ ŧĥę mįģřäŧįőʼn přőčęşş.",
"title": "Mįģřäŧę ŧő Ğřäƒäʼnä Cľőūđ įş įʼn pūþľįč přęvįęŵ",
"title-plugins": "Mįģřäŧįőʼn őƒ pľūģįʼnş"
},
"resource-details": {
"dismiss-button": "ØĶ",
@ -1937,7 +1940,6 @@
"generic-error": "Ŧĥęřę ĥäş þęęʼn äʼn ęřřőř ŵĥįľę mįģřäŧįʼnģ. Pľęäşę čĥęčĸ ŧĥę čľőūđ mįģřäŧįőʼn ľőģş ƒőř mőřę įʼnƒőřmäŧįőʼn.",
"internal-service-error": "Ŧĥęřę ĥäş þęęʼn äʼn ęřřőř ŵĥįľę mįģřäŧįʼnģ. Pľęäşę čĥęčĸ ŧĥę Ğřäƒäʼnä şęřvęř ľőģş ƒőř mőřę đęŧäįľş.",
"library-element-name-conflict": "Ŧĥęřę įş ä ľįþřäřy ęľęmęʼnŧ ŵįŧĥ ŧĥę şämę ʼnämę įʼn ŧĥę ŧäřģęŧ įʼnşŧäʼnčę. Ŗęʼnämę őʼnę őƒ ŧĥęm äʼnđ ŧřy äģäįʼn.",
"only-core-data-sources": "Øʼnľy čőřę đäŧä şőūřčęş äřę şūppőřŧęđ. Pľęäşę ęʼnşūřę ŧĥę pľūģįʼn įş įʼnşŧäľľęđ őʼn ŧĥę čľőūđ şŧäčĸ.",
"resource-conflict": "Ŧĥęřę įş ä řęşőūřčę čőʼnƒľįčŧ ŵįŧĥ ŧĥę ŧäřģęŧ įʼnşŧäʼnčę. Pľęäşę čĥęčĸ ŧĥę Ğřäƒäʼnä şęřvęř ľőģş ƒőř mőřę đęŧäįľş.",
"unexpected-error": "Ŧĥęřę ĥäş þęęʼn äʼn ęřřőř ŵĥįľę mįģřäŧįʼnģ. Pľęäşę čĥęčĸ ŧĥę Ğřäƒäʼnä şęřvęř ľőģş ƒőř mőřę đęŧäįľş.",
"unsupported-data-type": "Mįģřäŧįőʼn őƒ ŧĥįş đäŧä ŧypę įş ʼnőŧ čūřřęʼnŧľy şūppőřŧęđ."
@ -1976,6 +1978,7 @@
"mute_timing": "Mūŧę Ŧįmįʼnģ",
"notification_policy": "Ńőŧįƒįčäŧįőʼn Pőľįčy",
"notification_template": "Ńőŧįƒįčäŧįőʼn Ŧęmpľäŧę",
"plugin": "Pľūģįʼn",
"unknown": "Ůʼnĸʼnőŵʼn"
},
"summary": {

@ -7205,7 +7205,6 @@
"RESOURCE_CONFLICT",
"UNEXPECTED_STATUS_CODE",
"INTERNAL_SERVICE_ERROR",
"ONLY_CORE_DATA_SOURCES",
"GENERIC_ERROR"
],
"type": "string"
@ -7242,7 +7241,8 @@
"CONTACT_POINT",
"NOTIFICATION_POLICY",
"NOTIFICATION_TEMPLATE",
"MUTE_TIMING"
"MUTE_TIMING",
"PLUGIN"
],
"type": "string"
}

Loading…
Cancel
Save