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:
Marcus Efraimsson
2022-03-10 18:38:04 +01:00
committed by GitHub
co-authored by Will Browne
parent d076cabb60
commit 6c7d326499
32 changed files with 1434 additions and 579 deletions
@@ -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",
},
},
},
},
},
}
}
-135
View File
@@ -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
}
-64
View File
@@ -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)
}
-1
View File
@@ -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)