Plugins: Refactor GetPluginDashboards/LoadPluginDashboard (#46316)
Refactors GetPluginDashboards/LoadPluginDashboard by moving database interaction from plugin management to the plugindashboards service. Fixes #44553 Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
This commit is contained in:
co-authored by
Will Browne
parent
d076cabb60
commit
6c7d326499
@@ -0,0 +1,92 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var openDashboardFile = func(name string) (fs.File, error) {
|
||||
// Wrapping in filepath.Clean to properly handle
|
||||
// gosec G304 Potential file inclusion via variable rule.
|
||||
return os.Open(filepath.Clean(name))
|
||||
}
|
||||
|
||||
func (m *PluginManager) ListPluginDashboardFiles(ctx context.Context, args *plugins.ListPluginDashboardFilesArgs) (*plugins.ListPluginDashboardFilesResult, error) {
|
||||
if args == nil {
|
||||
return nil, fmt.Errorf("args cannot be nil")
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(args.PluginID)) == 0 {
|
||||
return nil, fmt.Errorf("args.PluginID cannot be empty")
|
||||
}
|
||||
|
||||
plugin, exists := m.Plugin(ctx, args.PluginID)
|
||||
if !exists {
|
||||
return nil, plugins.NotFoundError{PluginID: args.PluginID}
|
||||
}
|
||||
|
||||
references := []string{}
|
||||
for _, include := range plugin.DashboardIncludes() {
|
||||
references = append(references, include.Path)
|
||||
}
|
||||
|
||||
return &plugins.ListPluginDashboardFilesResult{
|
||||
FileReferences: references,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *PluginManager) GetPluginDashboardFileContents(ctx context.Context, args *plugins.GetPluginDashboardFileContentsArgs) (*plugins.GetPluginDashboardFileContentsResult, error) {
|
||||
if args == nil {
|
||||
return nil, fmt.Errorf("args cannot be nil")
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(args.PluginID)) == 0 {
|
||||
return nil, fmt.Errorf("args.PluginID cannot be empty")
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(args.FileReference)) == 0 {
|
||||
return nil, fmt.Errorf("args.FileReference cannot be empty")
|
||||
}
|
||||
|
||||
plugin, exists := m.Plugin(ctx, args.PluginID)
|
||||
if !exists {
|
||||
return nil, plugins.NotFoundError{PluginID: args.PluginID}
|
||||
}
|
||||
|
||||
var includedFile *plugins.Includes
|
||||
for _, include := range plugin.DashboardIncludes() {
|
||||
if args.FileReference == include.Path {
|
||||
includedFile = include
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if includedFile == nil {
|
||||
return nil, fmt.Errorf("plugin dashboard file not found")
|
||||
}
|
||||
|
||||
cleanPath, err := util.CleanRelativePath(includedFile.Path)
|
||||
if err != nil {
|
||||
// CleanRelativePath should clean and make the path relative so this is not expected to fail
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboardFilePath := filepath.Join(plugin.PluginDir, cleanPath)
|
||||
file, err := openDashboardFile(dashboardFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &plugins.GetPluginDashboardFileContentsResult{
|
||||
Content: file,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var _ plugins.DashboardFileStore = &PluginManager{}
|
||||
@@ -0,0 +1,223 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDashboardFileStore(t *testing.T) {
|
||||
m := setupPluginDashboardsForTest(t)
|
||||
|
||||
t.Run("Input validation", func(t *testing.T) {
|
||||
t.Run("ListPluginDashboardFiles", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
args *plugins.ListPluginDashboardFilesArgs
|
||||
}{
|
||||
{
|
||||
name: "nil args should return error",
|
||||
},
|
||||
{
|
||||
name: "empty args.PluginID should return error",
|
||||
args: &plugins.ListPluginDashboardFilesArgs{},
|
||||
},
|
||||
{
|
||||
name: "args.PluginID with only space should return error",
|
||||
args: &plugins.ListPluginDashboardFilesArgs{PluginID: " \t "},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := m.ListPluginDashboardFiles(context.Background(), tc.args)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetPluginDashboardFileContents", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
args *plugins.GetPluginDashboardFileContentsArgs
|
||||
}{
|
||||
{
|
||||
name: "nil args should return error",
|
||||
},
|
||||
{
|
||||
name: "empty args.PluginID should return error",
|
||||
args: &plugins.GetPluginDashboardFileContentsArgs{},
|
||||
},
|
||||
{
|
||||
name: "args.PluginID with only space should return error",
|
||||
args: &plugins.GetPluginDashboardFileContentsArgs{PluginID: " "},
|
||||
},
|
||||
{
|
||||
name: "empty args.FileReference should return error",
|
||||
args: &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithDashboards",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "args.FileReference with only space should return error",
|
||||
args: &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithDashboard",
|
||||
FileReference: " \t",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := m.GetPluginDashboardFileContents(context.Background(), tc.args)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Plugin without dashboards", func(t *testing.T) {
|
||||
t.Run("Should return zero file references", func(t *testing.T) {
|
||||
res, err := m.ListPluginDashboardFiles(context.Background(), &plugins.ListPluginDashboardFilesArgs{
|
||||
PluginID: "pluginWithoutDashboards",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Len(t, res.FileReferences, 0)
|
||||
})
|
||||
|
||||
t.Run("Should return file not found error when trying to get non-existing plugin dashboard file content", func(t *testing.T) {
|
||||
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithoutDashboards",
|
||||
FileReference: "dashboards/dash2.json",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "plugin dashboard file not found")
|
||||
require.Nil(t, res)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Plugin with dashboards", func(t *testing.T) {
|
||||
t.Run("Should return two file references", func(t *testing.T) {
|
||||
res, err := m.ListPluginDashboardFiles(context.Background(), &plugins.ListPluginDashboardFilesArgs{
|
||||
PluginID: "pluginWithDashboards",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Len(t, res.FileReferences, 2)
|
||||
})
|
||||
|
||||
t.Run("With filesystem", func(t *testing.T) {
|
||||
origOpenDashboardFile := openDashboardFile
|
||||
mapFs := fstest.MapFS{
|
||||
"plugins/plugin-id/dashboards/dash1.json": {
|
||||
Data: []byte("dash1"),
|
||||
},
|
||||
"plugins/plugin-id/dashboards/dash2.json": {
|
||||
Data: []byte("dash2"),
|
||||
},
|
||||
"plugins/plugin-id/dashboards/dash3.json": {
|
||||
Data: []byte("dash3"),
|
||||
},
|
||||
"plugins/plugin-id/dash2.json": {
|
||||
Data: []byte("dash2"),
|
||||
},
|
||||
}
|
||||
openDashboardFile = mapFs.Open
|
||||
t.Cleanup(func() {
|
||||
openDashboardFile = origOpenDashboardFile
|
||||
})
|
||||
|
||||
t.Run("Should return file not found error when trying to get non-existing plugin dashboard file content", func(t *testing.T) {
|
||||
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithDashboards",
|
||||
FileReference: "dashboards/dash3.json",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "plugin dashboard file not found")
|
||||
require.Nil(t, res)
|
||||
})
|
||||
|
||||
t.Run("Should return file content for dashboards/dash1.json", func(t *testing.T) {
|
||||
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithDashboards",
|
||||
FileReference: "dashboards/dash1.json",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.NotNil(t, res.Content)
|
||||
b, err := io.ReadAll(res.Content)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dash1", string(b))
|
||||
require.NoError(t, res.Content.Close())
|
||||
})
|
||||
|
||||
t.Run("Should return file content for dashboards/dash2.json", func(t *testing.T) {
|
||||
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithDashboards",
|
||||
FileReference: "dashboards/dash2.json",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.NotNil(t, res.Content)
|
||||
b, err := io.ReadAll(res.Content)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "dash2", string(b))
|
||||
require.NoError(t, res.Content.Close())
|
||||
})
|
||||
|
||||
t.Run("Should return error when trying to read relative file", func(t *testing.T) {
|
||||
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
|
||||
PluginID: "pluginWithDashboards",
|
||||
FileReference: "dashboards/../dash2.json",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.EqualError(t, err, "plugin dashboard file not found")
|
||||
require.Nil(t, res)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func setupPluginDashboardsForTest(t *testing.T) *PluginManager {
|
||||
t.Helper()
|
||||
|
||||
return &PluginManager{
|
||||
store: map[string]*plugins.Plugin{
|
||||
"pluginWithoutDashboards": {
|
||||
JSONData: plugins.JSONData{
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
Type: "page",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"pluginWithDashboards": {
|
||||
PluginDir: "plugins/plugin-id",
|
||||
JSONData: plugins.JSONData{
|
||||
Includes: []*plugins.Includes{
|
||||
{
|
||||
Type: "page",
|
||||
},
|
||||
{
|
||||
Type: "dashboard",
|
||||
Path: "dashboards/dash1.json",
|
||||
},
|
||||
{
|
||||
Type: "dashboard",
|
||||
Path: "dashboards/dash2.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func (m *PluginManager) GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
|
||||
plugin, exists := m.Plugin(ctx, pluginID)
|
||||
if !exists {
|
||||
return nil, plugins.NotFoundError{PluginID: pluginID}
|
||||
}
|
||||
|
||||
result := make([]*plugins.PluginDashboardInfoDTO, 0)
|
||||
|
||||
// load current dashboards
|
||||
query := models.GetDashboardsByPluginIdQuery{OrgId: orgID, PluginId: pluginID}
|
||||
if err := bus.Dispatch(ctx, &query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingMatches := make(map[int64]bool)
|
||||
for _, include := range plugin.Includes {
|
||||
if include.Type != plugins.TypeDashboard {
|
||||
continue
|
||||
}
|
||||
|
||||
dashboard, err := m.LoadPluginDashboard(ctx, plugin.ID, include.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &plugins.PluginDashboardInfoDTO{}
|
||||
res.UID = dashboard.Uid
|
||||
res.Path = include.Path
|
||||
res.PluginId = plugin.ID
|
||||
res.Title = dashboard.Title
|
||||
res.Revision = dashboard.Data.Get("revision").MustInt64(1)
|
||||
|
||||
// find existing dashboard
|
||||
for _, existingDash := range query.Result {
|
||||
if existingDash.Slug == dashboard.Slug {
|
||||
res.UID = existingDash.Uid
|
||||
res.DashboardId = existingDash.Id
|
||||
res.Imported = true
|
||||
res.ImportedUri = "db/" + existingDash.Slug
|
||||
res.ImportedUrl = existingDash.GetUrl()
|
||||
res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
|
||||
existingMatches[existingDash.Id] = true
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, res)
|
||||
}
|
||||
|
||||
// find deleted dashboards
|
||||
for _, dash := range query.Result {
|
||||
if _, exists := existingMatches[dash.Id]; !exists {
|
||||
result = append(result, &plugins.PluginDashboardInfoDTO{
|
||||
UID: dash.Uid,
|
||||
Slug: dash.Slug,
|
||||
DashboardId: dash.Id,
|
||||
Removed: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *PluginManager) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) {
|
||||
if len(strings.TrimSpace(pluginID)) == 0 {
|
||||
return nil, fmt.Errorf("pluginID cannot be empty")
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(path)) == 0 {
|
||||
return nil, fmt.Errorf("path cannot be empty")
|
||||
}
|
||||
|
||||
plugin, exists := m.Plugin(ctx, pluginID)
|
||||
if !exists {
|
||||
return nil, plugins.NotFoundError{PluginID: pluginID}
|
||||
}
|
||||
|
||||
cleanPath, err := util.CleanRelativePath(path)
|
||||
if err != nil {
|
||||
// CleanRelativePath should clean and make the path relative so this is not expected to fail
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboardFilePath := filepath.Join(plugin.PluginDir, cleanPath)
|
||||
|
||||
included := false
|
||||
for _, include := range plugin.DashboardIncludes() {
|
||||
if filepath.Join(plugin.PluginDir, include.Path) == dashboardFilePath {
|
||||
included = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !included {
|
||||
return nil, fmt.Errorf("dashboard not included in plugin")
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based
|
||||
// on plugin folder structure on disk and not user input. `path` input validation above
|
||||
// should only allow paths defined in the plugin's plugin.json.
|
||||
reader, err := os.Open(dashboardFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
m.log.Warn("Failed to close file", "path", dashboardFilePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := simplejson.NewFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewDashboardFromJson(data), nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetPluginDashboards(t *testing.T) {
|
||||
cfg := &setting.Cfg{
|
||||
PluginSettings: setting.PluginSettings{
|
||||
"test-app": map[string]string{
|
||||
"path": "testdata/test-app",
|
||||
},
|
||||
},
|
||||
}
|
||||
pmCfg := plugins.FromGrafanaCfg(cfg)
|
||||
pm, err := ProvideService(cfg, loader.New(pmCfg, nil,
|
||||
signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{}))
|
||||
require.NoError(t, err)
|
||||
|
||||
bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardQuery) error {
|
||||
if query.Slug == "nginx-connections" {
|
||||
dash := models.NewDashboard("Nginx Connections")
|
||||
dash.Data.Set("revision", "1.1")
|
||||
query.Result = dash
|
||||
return nil
|
||||
}
|
||||
|
||||
return models.ErrDashboardNotFound
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
|
||||
var data = simplejson.New()
|
||||
data.Set("title", "Nginx Connections")
|
||||
data.Set("revision", 22)
|
||||
|
||||
query.Result = []*models.Dashboard{
|
||||
{Slug: "nginx-connections", Data: data},
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
dashboards, err := pm.GetPluginDashboards(context.Background(), 1, "test-app")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, dashboards, 2)
|
||||
require.Equal(t, "Nginx Connections", dashboards[0].Title)
|
||||
require.Equal(t, int64(25), dashboards[0].Revision)
|
||||
require.Equal(t, int64(22), dashboards[0].ImportedRevision)
|
||||
require.Equal(t, "db/nginx-connections", dashboards[0].ImportedUri)
|
||||
|
||||
require.Equal(t, int64(2), dashboards[1].Revision)
|
||||
require.Equal(t, int64(0), dashboards[1].ImportedRevision)
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const (
|
||||
|
||||
var _ plugins.Client = (*PluginManager)(nil)
|
||||
var _ plugins.Store = (*PluginManager)(nil)
|
||||
var _ plugins.PluginDashboardManager = (*PluginManager)(nil)
|
||||
var _ plugins.StaticRouteResolver = (*PluginManager)(nil)
|
||||
var _ plugins.RendererManager = (*PluginManager)(nil)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user