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
parent d076cabb60
commit 6c7d326499
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1434 additions and 579 deletions

View File

@ -40,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@ -446,3 +447,31 @@ func mockRequestBody(v interface{}) io.ReadCloser {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
return io.NopCloser(bytes.NewReader(b)) return io.NopCloser(bytes.NewReader(b))
} }
// APITestServerOption option func for customizing HTTPServer configuration
// when setting up an API test server via SetupAPITestServer.
type APITestServerOption func(hs *HTTPServer)
// SetupAPITestServer sets up a webtest.Server ready for testing all
// routes registered via HTTPServer.registerRoutes().
// Optionally customize HTTPServer configuration by providing APITestServerOption
// option(s).
func SetupAPITestServer(t *testing.T, opts ...APITestServerOption) *webtest.Server {
t.Helper()
hs := &HTTPServer{
RouteRegister: routing.NewRouteRegister(),
Cfg: setting.NewCfg(),
AccessControl: accesscontrolmock.New().WithDisabled(),
Features: featuremgmt.WithFeatures(),
searchUsersService: &searchusers.OSSService{},
}
for _, opt := range opts {
opt(hs)
}
hs.registerRoutes()
s := webtest.NewServer(t, hs.RouteRegister)
return s
}

View File

@ -48,6 +48,7 @@ import (
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/plugindashboards"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
"github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/query"
@ -100,7 +101,7 @@ type HTTPServer struct {
PluginRequestValidator models.PluginRequestValidator PluginRequestValidator models.PluginRequestValidator
pluginClient plugins.Client pluginClient plugins.Client
pluginStore plugins.Store pluginStore plugins.Store
pluginDashboardManager plugins.PluginDashboardManager pluginDashboardService plugindashboards.Service
pluginStaticRouteResolver plugins.StaticRouteResolver pluginStaticRouteResolver plugins.StaticRouteResolver
pluginErrorResolver plugins.ErrorResolver pluginErrorResolver plugins.ErrorResolver
SearchService search.Service SearchService search.Service
@ -152,7 +153,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
renderService rendering.Service, licensing models.Licensing, hooksService *hooks.HooksService, renderService rendering.Service, licensing models.Licensing, hooksService *hooks.HooksService,
cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine, cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine,
pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver, pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver,
pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client, pluginDashboardService plugindashboards.Service, pluginStore plugins.Store, pluginClient plugins.Client,
pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider, pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider,
dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, dataSourceCache datasources.CacheService, userTokenService models.UserTokenService,
cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service, cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service,
@ -191,7 +192,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginClient: pluginClient, pluginClient: pluginClient,
pluginStore: pluginStore, pluginStore: pluginStore,
pluginStaticRouteResolver: pluginStaticRouteResolver, pluginStaticRouteResolver: pluginStaticRouteResolver,
pluginDashboardManager: pluginDashboardManager, pluginDashboardService: pluginDashboardService,
pluginErrorResolver: pluginErrorResolver, pluginErrorResolver: pluginErrorResolver,
grafanaUpdateChecker: grafanaUpdateChecker, grafanaUpdateChecker: grafanaUpdateChecker,
pluginsUpdateChecker: pluginsUpdateChecker, pluginsUpdateChecker: pluginsUpdateChecker,

View File

@ -0,0 +1,35 @@
package api
import (
"errors"
"net/http"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/web"
)
// GetPluginDashboards get plugin dashboards.
//
// /api/plugins/:pluginId/dashboards
func (hs *HTTPServer) GetPluginDashboards(c *models.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
listReq := &plugindashboards.ListPluginDashboardsRequest{
OrgID: c.OrgId,
PluginID: pluginID,
}
list, err := hs.pluginDashboardService.ListPluginDashboards(c.Req.Context(), listReq)
if err != nil {
var notFound plugins.NotFoundError
if errors.As(err, &notFound) {
return response.Error(http.StatusNotFound, notFound.Error(), nil)
}
return response.Error(http.StatusInternalServerError, "Failed to get plugin dashboards", err)
}
return response.JSON(http.StatusOK, list.Items)
}

View File

@ -0,0 +1,128 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/require"
)
func TestGetPluginDashboards(t *testing.T) {
const existingPluginID = "existing-plugin"
pluginDashboardService := &pluginDashboardServiceMock{
pluginDashboards: map[string][]*plugindashboards.PluginDashboard{
existingPluginID: {
{
PluginId: existingPluginID,
UID: "a",
Title: "A",
},
{
PluginId: existingPluginID,
UID: "b",
Title: "B",
},
},
},
unexpectedErrors: map[string]error{
"boom": fmt.Errorf("BOOM"),
},
}
s := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.pluginDashboardService = pluginDashboardService
})
t.Run("Not signed in should return 404 Not Found", func(t *testing.T) {
req := s.NewGetRequest("/api/plugins/test/dashboards")
resp, err := s.Send(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("Signed in and not org admin should return 403 Forbidden", func(t *testing.T) {
user := &models.SignedInUser{
UserId: 1,
OrgRole: models.ROLE_EDITOR,
}
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, existingPluginID, user)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Signed in and org admin", func(t *testing.T) {
user := &models.SignedInUser{
UserId: 1,
OrgId: 1,
OrgRole: models.ROLE_ADMIN,
}
t.Run("When plugin doesn't exist should return 404 Not Found", func(t *testing.T) {
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, "not-exists", user)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("When result is unexpected error should return 500 Internal Server Error", func(t *testing.T) {
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, "boom", user)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
t.Run("When plugin exists should return 200 OK with expected payload", func(t *testing.T) {
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, existingPluginID, user)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
bytes, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
var listResp []*plugindashboards.PluginDashboard
err = json.Unmarshal(bytes, &listResp)
require.NoError(t, err)
require.NotNil(t, listResp)
require.Len(t, listResp, 2)
require.Equal(t, pluginDashboardService.pluginDashboards[existingPluginID], listResp)
})
})
}
func sendGetPluginDashboardsRequestForSignedInUser(t *testing.T, s *webtest.Server, pluginID string, user *models.SignedInUser) (*http.Response, error) {
t.Helper()
req := s.NewGetRequest(fmt.Sprintf("/api/plugins/%s/dashboards", pluginID))
webtest.RequestWithSignedInUser(req, user)
return s.Send(req)
}
type pluginDashboardServiceMock struct {
plugindashboards.Service
pluginDashboards map[string][]*plugindashboards.PluginDashboard
unexpectedErrors map[string]error
}
func (m *pluginDashboardServiceMock) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
if pluginDashboards, exists := m.pluginDashboards[req.PluginID]; exists {
return &plugindashboards.ListPluginDashboardsResponse{
Items: pluginDashboards,
}, nil
}
if err, exists := m.unexpectedErrors[req.PluginID]; exists {
return nil, err
}
return nil, plugins.NotFoundError{PluginID: req.PluginID}
}

View File

@ -182,22 +182,6 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Respons
return response.Success("Plugin settings updated") return response.Success("Plugin settings updated")
} }
func (hs *HTTPServer) GetPluginDashboards(c *models.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
list, err := hs.pluginDashboardManager.GetPluginDashboards(c.Req.Context(), c.OrgId, pluginID)
if err != nil {
var notFound plugins.NotFoundError
if errors.As(err, &notFound) {
return response.Error(404, notFound.Error(), nil)
}
return response.Error(500, "Failed to get plugin dashboards", err)
}
return response.JSON(200, list)
}
func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response { func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"] pluginID := web.Params(c.Req)[":pluginId"]
name := web.Params(c.Req)[":name"] name := web.Params(c.Req)[":name"]

View File

@ -2,10 +2,10 @@ package plugins
import ( import (
"context" "context"
"io"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin"
) )
@ -73,26 +73,32 @@ type PluginLoaderAuthorizer interface {
CanLoadPlugin(plugin *Plugin) bool CanLoadPlugin(plugin *Plugin) bool
} }
type PluginDashboardInfoDTO struct { // ListPluginDashboardFilesArgs list plugin dashboard files argument model.
UID string `json:"uid"` type ListPluginDashboardFilesArgs struct {
PluginId string `json:"pluginId"` PluginID string
Title string `json:"title"`
Imported bool `json:"imported"`
ImportedUri string `json:"importedUri"`
ImportedUrl string `json:"importedUrl"`
Slug string `json:"slug"`
DashboardId int64 `json:"dashboardId"`
FolderId int64 `json:"folderId"`
ImportedRevision int64 `json:"importedRevision"`
Revision int64 `json:"revision"`
Description string `json:"description"`
Path string `json:"path"`
Removed bool `json:"removed"`
} }
type PluginDashboardManager interface { // GetPluginDashboardFilesArgs list plugin dashboard files result model.
// GetPluginDashboards gets dashboards for a certain org/plugin. type ListPluginDashboardFilesResult struct {
GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*PluginDashboardInfoDTO, error) FileReferences []string
// LoadPluginDashboard loads a plugin dashboard. }
LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error)
// GetPluginDashboardFileContentsArgs get plugin dashboard file content argument model.
type GetPluginDashboardFileContentsArgs struct {
PluginID string
FileReference string
}
// GetPluginDashboardFileContentsResult get plugin dashboard file content result model.
type GetPluginDashboardFileContentsResult struct {
Content io.ReadCloser
}
// DashboardFileStore is the interface for plugin dashboard file storage.
type DashboardFileStore interface {
// ListPluginDashboardFiles lists plugin dashboard files.
ListPluginDashboardFiles(ctx context.Context, args *ListPluginDashboardFilesArgs) (*ListPluginDashboardFilesResult, error)
// GetPluginDashboardFileContents gets the referenced plugin dashboard file content.
GetPluginDashboardFileContents(ctx context.Context, args *GetPluginDashboardFileContentsArgs) (*GetPluginDashboardFileContentsResult, error)
} }

View File

@ -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{}

View File

@ -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",
},
},
},
},
},
}
}

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
}

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)
}

View File

@ -24,7 +24,6 @@ const (
var _ plugins.Client = (*PluginManager)(nil) var _ plugins.Client = (*PluginManager)(nil)
var _ plugins.Store = (*PluginManager)(nil) var _ plugins.Store = (*PluginManager)(nil)
var _ plugins.PluginDashboardManager = (*PluginManager)(nil)
var _ plugins.StaticRouteResolver = (*PluginManager)(nil) var _ plugins.StaticRouteResolver = (*PluginManager)(nil)
var _ plugins.RendererManager = (*PluginManager)(nil) var _ plugins.RendererManager = (*PluginManager)(nil)

View File

@ -17,7 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/live/pushhttp" "github.com/grafana/grafana/pkg/services/live/pushhttp"
"github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/plugindashboards" plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service"
"github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/rendering"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
@ -35,8 +35,9 @@ func ProvideBackgroundServiceRegistry(
metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService, metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService,
remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service,
// Need to make sure these are initialized, is there a better place to put them? // Need to make sure these are initialized, is there a better place to put them?
_ *plugindashboards.Service, _ *dashboardsnapshots.Service, _ *dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
_ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, _ serviceaccounts.Service, _ *guardian.Provider,
_ *plugindashboardsservice.DashboardUpdater,
) *BackgroundServiceRegistry { ) *BackgroundServiceRegistry {
return NewBackgroundServiceRegistry( return NewBackgroundServiceRegistry(
httpServer, httpServer,

View File

@ -58,6 +58,7 @@ import (
"github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/plugindashboards"
plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service"
"github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsettings"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
"github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/query"
@ -126,8 +127,8 @@ var wireBasicSet = wire.NewSet(
manager.ProvideService, manager.ProvideService,
wire.Bind(new(plugins.Client), new(*manager.PluginManager)), wire.Bind(new(plugins.Client), new(*manager.PluginManager)),
wire.Bind(new(plugins.Store), new(*manager.PluginManager)), wire.Bind(new(plugins.Store), new(*manager.PluginManager)),
wire.Bind(new(plugins.DashboardFileStore), new(*manager.PluginManager)),
wire.Bind(new(plugins.StaticRouteResolver), new(*manager.PluginManager)), wire.Bind(new(plugins.StaticRouteResolver), new(*manager.PluginManager)),
wire.Bind(new(plugins.PluginDashboardManager), new(*manager.PluginManager)),
wire.Bind(new(plugins.RendererManager), new(*manager.PluginManager)), wire.Bind(new(plugins.RendererManager), new(*manager.PluginManager)),
coreplugin.ProvideCoreRegistry, coreplugin.ProvideCoreRegistry,
loader.ProvideService, loader.ProvideService,
@ -212,11 +213,14 @@ var wireBasicSet = wire.NewSet(
dashboardstore.ProvideDashboardStore, dashboardstore.ProvideDashboardStore,
wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)), wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)),
wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)), wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)),
wire.Bind(new(dashboards.PluginService), new(*dashboardservice.DashboardServiceImpl)),
wire.Bind(new(dashboards.FolderService), new(*dashboardservice.FolderServiceImpl)), wire.Bind(new(dashboards.FolderService), new(*dashboardservice.FolderServiceImpl)),
wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)), wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)),
dashboardimportservice.ProvideService, dashboardimportservice.ProvideService,
wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)), wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)),
plugindashboards.ProvideService, plugindashboardsservice.ProvideService,
wire.Bind(new(plugindashboards.Service), new(*plugindashboardsservice.Service)),
plugindashboardsservice.ProvideDashboardUpdater,
alerting.ProvideDashAlertExtractorService, alerting.ProvideDashAlertExtractorService,
wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)), wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)),
comments.ProvideService, comments.ProvideService,

View File

@ -43,9 +43,8 @@ func TestImportDashboardAPI(t *testing.T) {
cmd := &dashboardimport.ImportDashboardRequest{} cmd := &dashboardimport.ImportDashboardRequest{}
jsonBytes, err := json.Marshal(cmd) jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err) require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json") resp, err := s.SendJSON(req)
resp, err := s.Send(req)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode) require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
@ -58,12 +57,11 @@ func TestImportDashboardAPI(t *testing.T) {
} }
jsonBytes, err := json.Marshal(cmd) jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err) require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &models.SignedInUser{ webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1, UserId: 1,
}) })
resp, err := s.Send(req) resp, err := s.SendJSON(req)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
@ -75,12 +73,11 @@ func TestImportDashboardAPI(t *testing.T) {
} }
jsonBytes, err := json.Marshal(cmd) jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err) require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &models.SignedInUser{ webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1, UserId: 1,
}) })
resp, err := s.Send(req) resp, err := s.SendJSON(req)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
@ -93,12 +90,11 @@ func TestImportDashboardAPI(t *testing.T) {
} }
jsonBytes, err := json.Marshal(cmd) jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err) require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) req := s.NewPostRequest("/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &models.SignedInUser{ webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1, UserId: 1,
}) })
resp, err := s.Send(req) resp, err := s.SendJSON(req)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
@ -136,12 +132,11 @@ func TestImportDashboardAPI(t *testing.T) {
} }
jsonBytes, err := json.Marshal(cmd) jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err) require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) req := s.NewPostRequest("/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &models.SignedInUser{ webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1, UserId: 1,
}) })
resp, err := s.Send(req) resp, err := s.SendJSON(req)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, http.StatusOK, resp.StatusCode)
@ -165,12 +160,11 @@ func TestImportDashboardAPI(t *testing.T) {
} }
jsonBytes, err := json.Marshal(cmd) jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err) require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &models.SignedInUser{ webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1, UserId: 1,
}) })
resp, err := s.Send(req) resp, err := s.SendJSON(req)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, resp.Body.Close()) require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode) require.Equal(t, http.StatusForbidden, resp.StatusCode)

View File

@ -12,18 +12,19 @@ import (
"github.com/grafana/grafana/pkg/services/dashboardimport/utils" "github.com/grafana/grafana/pkg/services/dashboardimport/utils"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/schemaloader" "github.com/grafana/grafana/pkg/services/schemaloader"
) )
func ProvideService(routeRegister routing.RouteRegister, func ProvideService(routeRegister routing.RouteRegister,
quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService, quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService,
pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginDashboardService plugindashboards.Service, pluginStore plugins.Store,
libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService, libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService,
ac accesscontrol.AccessControl, ac accesscontrol.AccessControl,
) *ImportDashboardService { ) *ImportDashboardService {
s := &ImportDashboardService{ s := &ImportDashboardService{
pluginDashboardManager: pluginDashboardManager, pluginDashboardService: pluginDashboardService,
dashboardService: dashboardService, dashboardService: dashboardService,
libraryPanelService: libraryPanelService, libraryPanelService: libraryPanelService,
} }
@ -35,7 +36,7 @@ func ProvideService(routeRegister routing.RouteRegister,
} }
type ImportDashboardService struct { type ImportDashboardService struct {
pluginDashboardManager plugins.PluginDashboardManager pluginDashboardService plugindashboards.Service
dashboardService dashboards.DashboardService dashboardService dashboards.DashboardService
libraryPanelService librarypanels.Service libraryPanelService librarypanels.Service
} }
@ -43,9 +44,14 @@ type ImportDashboardService struct {
func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) {
var dashboard *models.Dashboard var dashboard *models.Dashboard
if req.PluginId != "" { if req.PluginId != "" {
var err error loadReq := &plugindashboards.LoadPluginDashboardRequest{
if dashboard, err = s.pluginDashboardManager.LoadPluginDashboard(ctx, req.PluginId, req.Path); err != nil { PluginID: req.PluginId,
Reference: req.Path,
}
if resp, err := s.pluginDashboardService.LoadPluginDashboard(ctx, loadReq); err != nil {
return nil, err return nil, err
} else {
dashboard = resp.Dashboard
} }
} else { } else {
dashboard = models.NewDashboardFromJson(req.Dashboard) dashboard = models.NewDashboardFromJson(req.Dashboard)

View File

@ -8,16 +8,16 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestImportDashboardService(t *testing.T) { func TestImportDashboardService(t *testing.T) {
t.Run("When importing a plugin dashboard should save dashboard and sync library panels", func(t *testing.T) { t.Run("When importing a plugin dashboard should save dashboard and sync library panels", func(t *testing.T) {
pluginDashboardManager := &pluginDashboardManagerMock{ pluginDashboardService := &pluginDashboardServiceMock{
loadPluginDashboardFunc: loadTestDashboard, loadPluginDashboardFunc: loadTestDashboard,
} }
@ -52,7 +52,7 @@ func TestImportDashboardService(t *testing.T) {
}, },
} }
s := &ImportDashboardService{ s := &ImportDashboardService{
pluginDashboardManager: pluginDashboardManager, pluginDashboardService: pluginDashboardService,
dashboardService: dashboardService, dashboardService: dashboardService,
libraryPanelService: libraryPanelService, libraryPanelService: libraryPanelService,
} }
@ -108,11 +108,14 @@ func TestImportDashboardService(t *testing.T) {
libraryPanelService: libraryPanelService, libraryPanelService: libraryPanelService,
} }
dash, err := loadTestDashboard(context.Background(), "", "dashboard.json") loadResp, err := loadTestDashboard(context.Background(), &plugindashboards.LoadPluginDashboardRequest{
PluginID: "",
Reference: "dashboard.json",
})
require.NoError(t, err) require.NoError(t, err)
req := &dashboardimport.ImportDashboardRequest{ req := &dashboardimport.ImportDashboardRequest{
Dashboard: dash.Data, Dashboard: loadResp.Dashboard.Data,
Path: "plugin_dashboard.json", Path: "plugin_dashboard.json",
Inputs: []dashboardimport.ImportDashboardInput{ Inputs: []dashboardimport.ImportDashboardInput{
{Name: "*", Type: "datasource", Value: "prom"}, {Name: "*", Type: "datasource", Value: "prom"},
@ -136,10 +139,10 @@ func TestImportDashboardService(t *testing.T) {
}) })
} }
func loadTestDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { func loadTestDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
// It's safe to ignore gosec warning G304 since this is a test and arguments comes from test configuration. // It's safe to ignore gosec warning G304 since this is a test and arguments comes from test configuration.
// nolint:gosec // nolint:gosec
bytes, err := ioutil.ReadFile(filepath.Join("testdata", path)) bytes, err := ioutil.ReadFile(filepath.Join("testdata", req.Reference))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -149,17 +152,19 @@ func loadTestDashboard(ctx context.Context, pluginID, path string) (*models.Dash
return nil, err return nil, err
} }
return models.NewDashboardFromJson(dashboardJSON), nil return &plugindashboards.LoadPluginDashboardResponse{
Dashboard: models.NewDashboardFromJson(dashboardJSON),
}, nil
} }
type pluginDashboardManagerMock struct { type pluginDashboardServiceMock struct {
plugins.PluginDashboardManager plugindashboards.Service
loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) loadPluginDashboardFunc func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error)
} }
func (m *pluginDashboardManagerMock) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { func (m *pluginDashboardServiceMock) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
if m.loadPluginDashboardFunc != nil { if m.loadPluginDashboardFunc != nil {
return m.loadPluginDashboardFunc(ctx, pluginID, path) return m.loadPluginDashboardFunc(ctx, req)
} }
return nil, nil return nil, nil

View File

@ -6,6 +6,8 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
) )
//go:generate mockery --name Store --structname FakeDashboardStore --inpackage --filename database_mock.go
// DashboardService is a service for operating on dashboards. // DashboardService is a service for operating on dashboards.
type DashboardService interface { type DashboardService interface {
SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error)
@ -16,6 +18,11 @@ type DashboardService interface {
UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error
} }
// PluginService is a service for operating on plugin dashboards.
type PluginService interface {
GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
}
//go:generate mockery --name DashboardProvisioningService --structname FakeDashboardProvisioning --inpackage --filename dashboard_provisioning_mock.go //go:generate mockery --name DashboardProvisioningService --structname FakeDashboardProvisioning --inpackage --filename dashboard_provisioning_mock.go
// DashboardProvisioningService is a service for operating on provisioned dashboards. // DashboardProvisioningService is a service for operating on provisioned dashboards.
type DashboardProvisioningService interface { type DashboardProvisioningService interface {
@ -29,7 +36,6 @@ type DashboardProvisioningService interface {
DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error
} }
//go:generate mockery --name Store --structname FakeDashboardStore --inpackage --filename database_mock.go
// Store is a dashboard store. // Store is a dashboard store.
type Store interface { type Store interface {
// ValidateDashboardBeforeSave validates a dashboard before save. // ValidateDashboardBeforeSave validates a dashboard before save.
@ -46,4 +52,6 @@ type Store interface {
// SaveAlerts saves dashboard alerts. // SaveAlerts saves dashboard alerts.
SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error
UnprovisionDashboard(ctx context.Context, id int64) error UnprovisionDashboard(ctx context.Context, id int64) error
// GetDashboardsByPluginID retrieves dashboards identified by plugin.
GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
} }

View File

@ -635,3 +635,14 @@ func EnsureTagsExist(sess *sqlstore.DBSession, tags []*models.Tag) ([]*models.Ta
return tags, nil return tags, nil
} }
func (d *DashboardStore) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return d.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
var dashboards = make([]*models.Dashboard, 0)
whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.sqlStore.Dialect.BooleanStr(false)
err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards)
query.Result = dashboards
return err
})
}

View File

@ -421,7 +421,7 @@ func TestDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) {
OrgId: 1, OrgId: 1,
} }
err := sqlStore.GetDashboardsByPluginId(context.Background(), &query) err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, len(query.Result), 2) require.Equal(t, len(query.Result), 2)
} }

View File

@ -5,9 +5,8 @@ package dashboards
import ( import (
context "context" context "context"
mock "github.com/stretchr/testify/mock"
models "github.com/grafana/grafana/pkg/models" models "github.com/grafana/grafana/pkg/models"
mock "github.com/stretchr/testify/mock"
) )
// FakeDashboardStore is an autogenerated mock type for the Store type // FakeDashboardStore is an autogenerated mock type for the Store type
@ -29,6 +28,20 @@ func (_m *FakeDashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Co
return r0 return r0
} }
// GetDashboardsByPluginID provides a mock function with given fields: ctx, query
func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
ret := _m.Called(ctx, query)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *models.GetDashboardsByPluginIdQuery) error); ok {
r0 = rf(ctx, query)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetFolderByTitle provides a mock function with given fields: orgID, title // GetFolderByTitle provides a mock function with given fields: orgID, title
func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) {
ret := _m.Called(orgID, title) ret := _m.Called(orgID, title)

View File

@ -443,6 +443,10 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo
return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId) return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId)
} }
func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return dr.dashboardStore.GetDashboardsByPluginID(ctx, query)
}
func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *m.SaveDashboardDTO, dash *models.Dashboard, provisioned bool) error { func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *m.SaveDashboardDTO, dash *models.Dashboard, provisioned bool) error {
inFolder := dash.FolderId > 0 inFolder := dash.FolderId > 0
if dr.features.IsEnabled(featuremgmt.FlagAccesscontrol) { if dr.features.IsEnabled(featuremgmt.FlagAccesscontrol) {

View File

@ -2,166 +2,55 @@ package plugindashboards
import ( import (
"context" "context"
"fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/pluginsettings"
) )
func ProvideService(bus bus.Bus, pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager, // PluginDashboard plugin dashboard model..
dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service) *Service { type PluginDashboard struct {
s := newService(bus, pluginStore, pluginDashboardManager, dashboardImportService, pluginSettingsService) UID string `json:"uid"`
s.updateAppDashboards() PluginId string `json:"pluginId"`
return s Title string `json:"title"`
Imported bool `json:"imported"`
ImportedUri string `json:"importedUri"`
ImportedUrl string `json:"importedUrl"`
Slug string `json:"slug"`
DashboardId int64 `json:"dashboardId"`
FolderId int64 `json:"folderId"`
ImportedRevision int64 `json:"importedRevision"`
Revision int64 `json:"revision"`
Description string `json:"description"`
Reference string `json:"path"`
Removed bool `json:"removed"`
} }
func newService(bus bus.Bus, pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager, // ListPluginDashboardsRequest request object for listing plugin dashboards.
dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service) *Service { type ListPluginDashboardsRequest struct {
s := &Service{ OrgID int64
bus: bus, PluginID string
pluginStore: pluginStore,
pluginDashboardManager: pluginDashboardManager,
dashboardImportService: dashboardImportService,
pluginSettingsService: pluginSettingsService,
logger: log.New("plugindashboards"),
}
bus.AddEventListener(s.handlePluginStateChanged)
return s
} }
type Service struct { // ListPluginDashboardsResponse response object for listing plugin dashboards.
bus bus.Bus type ListPluginDashboardsResponse struct {
pluginStore plugins.Store Items []*PluginDashboard
pluginDashboardManager plugins.PluginDashboardManager
dashboardImportService dashboardimport.Service
pluginSettingsService pluginsettings.Service
logger log.Logger
} }
func (s *Service) updateAppDashboards() { // LoadPluginDashboardRequest request object for loading a plugin dashboard.
s.logger.Debug("Looking for app dashboard updates") type LoadPluginDashboardRequest struct {
PluginID string
pluginSettings, err := s.pluginSettingsService.GetPluginSettings(context.Background(), 0) Reference string
if err != nil {
s.logger.Error("Failed to get all plugin settings", "error", err)
return
}
for _, pluginSetting := range pluginSettings {
// ignore disabled plugins
if !pluginSetting.Enabled {
continue
}
if pluginDef, exists := s.pluginStore.Plugin(context.Background(), pluginSetting.PluginId); exists {
if pluginDef.Info.Version != pluginSetting.PluginVersion {
s.syncPluginDashboards(context.Background(), pluginDef, pluginSetting.OrgId)
}
}
}
} }
func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.PluginDTO, orgID int64) { // LoadPluginDashboardResponse response object for loading a plugin dashboard.
s.logger.Info("Syncing plugin dashboards to DB", "pluginId", plugin.ID) type LoadPluginDashboardResponse struct {
Dashboard *models.Dashboard
// Get plugin dashboards
dashboards, err := s.pluginDashboardManager.GetPluginDashboards(ctx, orgID, plugin.ID)
if err != nil {
s.logger.Error("Failed to load app dashboards", "error", err)
return
}
// Update dashboards with updated revisions
for _, dash := range dashboards {
// remove removed ones
if dash.Removed {
s.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug)
deleteCmd := models.DeleteDashboardCommand{OrgId: orgID, Id: dash.DashboardId}
if err := s.bus.Dispatch(ctx, &deleteCmd); err != nil {
s.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
continue
}
// update updated ones
if dash.ImportedRevision != dash.Revision {
if err := s.autoUpdateAppDashboard(ctx, dash, orgID); err != nil {
s.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
}
}
// update version in plugin_setting table to mark that we have processed the update
query := models.GetPluginSettingByIdQuery{PluginId: plugin.ID, OrgId: orgID}
if err := s.pluginSettingsService.GetPluginSettingById(ctx, &query); err != nil {
s.logger.Error("Failed to read plugin setting by ID", "error", err)
return
}
appSetting := query.Result
cmd := models.UpdatePluginSettingVersionCmd{
OrgId: appSetting.OrgId,
PluginId: appSetting.PluginId,
PluginVersion: plugin.Info.Version,
}
if err := s.pluginSettingsService.UpdatePluginSettingVersion(ctx, &cmd); err != nil {
s.logger.Error("Failed to update plugin setting version", "error", err)
}
} }
func (s *Service) handlePluginStateChanged(ctx context.Context, event *models.PluginStateChangedEvent) error { // Service interface for listing plugin dashboards.
s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled) type Service interface {
// ListPluginDashboards list plugin dashboards identified by org/plugin.
ListPluginDashboards(ctx context.Context, req *ListPluginDashboardsRequest) (*ListPluginDashboardsResponse, error)
if event.Enabled { // LoadPluginDashboard loads a plugin dashboard identified by plugin and reference.
p, exists := s.pluginStore.Plugin(ctx, event.PluginId) LoadPluginDashboard(ctx context.Context, req *LoadPluginDashboardRequest) (*LoadPluginDashboardResponse, error)
if !exists {
return fmt.Errorf("plugin %s not found. Could not sync plugin dashboards", event.PluginId)
}
s.syncPluginDashboards(ctx, p, event.OrgId)
} else {
query := models.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId}
if err := s.bus.Dispatch(ctx, &query); err != nil {
return err
}
for _, dash := range query.Result {
s.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
deleteCmd := models.DeleteDashboardCommand{OrgId: dash.OrgId, Id: dash.Id}
if err := s.bus.Dispatch(ctx, &deleteCmd); err != nil {
return err
}
}
}
return nil
}
func (s *Service) autoUpdateAppDashboard(ctx context.Context, pluginDashInfo *plugins.PluginDashboardInfoDTO, orgID int64) error {
dash, err := s.pluginDashboardManager.LoadPluginDashboard(ctx, pluginDashInfo.PluginId, pluginDashInfo.Path)
if err != nil {
return err
}
s.logger.Info("Auto updating App dashboard", "dashboard", dash.Title, "newRev",
pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision)
_, err = s.dashboardImportService.ImportDashboard(ctx, &dashboardimport.ImportDashboardRequest{
PluginId: pluginDashInfo.PluginId,
User: &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN, OrgId: orgID},
Path: pluginDashInfo.Path,
FolderId: 0,
Dashboard: dash.Data,
Overwrite: true,
Inputs: nil,
})
return err
} }

View File

@ -0,0 +1,181 @@
package service
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsettings"
)
func ProvideDashboardUpdater(bus bus.Bus, pluginStore plugins.Store, pluginDashboardService plugindashboards.Service,
dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service,
dashboardPluginService dashboards.PluginService, dashboardService dashboards.DashboardService) *DashboardUpdater {
du := newDashboardUpdater(bus, pluginStore, pluginDashboardService, dashboardImportService,
pluginSettingsService, dashboardPluginService, dashboardService)
du.updateAppDashboards()
return du
}
func newDashboardUpdater(bus bus.Bus, pluginStore plugins.Store,
pluginDashboardService plugindashboards.Service, dashboardImportService dashboardimport.Service,
pluginSettingsService pluginsettings.Service, dashboardPluginService dashboards.PluginService,
dashboardService dashboards.DashboardService) *DashboardUpdater {
s := &DashboardUpdater{
pluginStore: pluginStore,
pluginDashboardService: pluginDashboardService,
dashboardImportService: dashboardImportService,
pluginSettingsService: pluginSettingsService,
dashboardPluginService: dashboardPluginService,
dashboardService: dashboardService,
logger: log.New("plugindashboards"),
}
bus.AddEventListener(s.handlePluginStateChanged)
return s
}
type DashboardUpdater struct {
pluginStore plugins.Store
pluginDashboardService plugindashboards.Service
dashboardImportService dashboardimport.Service
pluginSettingsService pluginsettings.Service
dashboardPluginService dashboards.PluginService
dashboardService dashboards.DashboardService
logger log.Logger
}
func (du *DashboardUpdater) updateAppDashboards() {
du.logger.Debug("Looking for app dashboard updates")
pluginSettings, err := du.pluginSettingsService.GetPluginSettings(context.Background(), 0)
if err != nil {
du.logger.Error("Failed to get all plugin settings", "error", err)
return
}
for _, pluginSetting := range pluginSettings {
// ignore disabled plugins
if !pluginSetting.Enabled {
continue
}
if pluginDef, exists := du.pluginStore.Plugin(context.Background(), pluginSetting.PluginId); exists {
if pluginDef.Info.Version != pluginSetting.PluginVersion {
du.syncPluginDashboards(context.Background(), pluginDef, pluginSetting.OrgId)
}
}
}
}
func (du *DashboardUpdater) syncPluginDashboards(ctx context.Context, plugin plugins.PluginDTO, orgID int64) {
du.logger.Info("Syncing plugin dashboards to DB", "pluginId", plugin.ID)
// Get plugin dashboards
req := &plugindashboards.ListPluginDashboardsRequest{
OrgID: orgID,
PluginID: plugin.ID,
}
resp, err := du.pluginDashboardService.ListPluginDashboards(ctx, req)
if err != nil {
du.logger.Error("Failed to load app dashboards", "error", err)
return
}
// Update dashboards with updated revisions
for _, dash := range resp.Items {
// remove removed ones
if dash.Removed {
du.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug)
if err := du.dashboardService.DeleteDashboard(ctx, dash.DashboardId, orgID); err != nil {
du.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
continue
}
// update updated ones
if dash.ImportedRevision != dash.Revision {
if err := du.autoUpdateAppDashboard(ctx, dash, orgID); err != nil {
du.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
}
}
// update version in plugin_setting table to mark that we have processed the update
query := models.GetPluginSettingByIdQuery{PluginId: plugin.ID, OrgId: orgID}
if err := du.pluginSettingsService.GetPluginSettingById(ctx, &query); err != nil {
du.logger.Error("Failed to read plugin setting by ID", "error", err)
return
}
appSetting := query.Result
cmd := models.UpdatePluginSettingVersionCmd{
OrgId: appSetting.OrgId,
PluginId: appSetting.PluginId,
PluginVersion: plugin.Info.Version,
}
if err := du.pluginSettingsService.UpdatePluginSettingVersion(ctx, &cmd); err != nil {
du.logger.Error("Failed to update plugin setting version", "error", err)
}
}
func (du *DashboardUpdater) handlePluginStateChanged(ctx context.Context, event *models.PluginStateChangedEvent) error {
du.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
if event.Enabled {
p, exists := du.pluginStore.Plugin(ctx, event.PluginId)
if !exists {
return fmt.Errorf("plugin %s not found. Could not sync plugin dashboards", event.PluginId)
}
du.syncPluginDashboards(ctx, p, event.OrgId)
} else {
query := models.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId}
if err := du.dashboardPluginService.GetDashboardsByPluginID(ctx, &query); err != nil {
return err
}
for _, dash := range query.Result {
du.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
if err := du.dashboardService.DeleteDashboard(ctx, dash.Id, dash.OrgId); err != nil {
return err
}
}
}
return nil
}
func (du *DashboardUpdater) autoUpdateAppDashboard(ctx context.Context, pluginDashInfo *plugindashboards.PluginDashboard, orgID int64) error {
req := &plugindashboards.LoadPluginDashboardRequest{
PluginID: pluginDashInfo.PluginId,
Reference: pluginDashInfo.Reference,
}
resp, err := du.pluginDashboardService.LoadPluginDashboard(ctx, req)
if err != nil {
return err
}
du.logger.Info("Auto updating App dashboard", "dashboard", resp.Dashboard.Title, "newRev",
pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision)
_, err = du.dashboardImportService.ImportDashboard(ctx, &dashboardimport.ImportDashboardRequest{
PluginId: pluginDashInfo.PluginId,
User: &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN, OrgId: orgID},
Path: pluginDashInfo.Reference,
FolderId: 0,
Dashboard: resp.Dashboard.Data,
Overwrite: true,
Inputs: nil,
})
return err
}

View File

@ -1,4 +1,4 @@
package plugindashboards package service
import ( import (
"context" "context"
@ -9,19 +9,21 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsettings/service" "github.com/grafana/grafana/pkg/services/pluginsettings/service"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestService(t *testing.T) { func TestDashboardUpdater(t *testing.T) {
t.Run("updateAppDashboards", func(t *testing.T) { t.Run("updateAppDashboards", func(t *testing.T) {
scenario(t, "Without any stored plugin settings shouldn't delete/import any dashboards", scenario(t, "Without any stored plugin settings shouldn't delete/import any dashboards",
scenarioInput{}, func(ctx *scenarioContext) { scenarioInput{}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards() ctx.dashboardUpdater.updateAppDashboards()
require.Len(t, ctx.pluginSettingsService.getPluginSettingsArgs, 1) require.Len(t, ctx.pluginSettingsService.getPluginSettingsArgs, 1)
require.Equal(t, int64(0), ctx.pluginSettingsService.getPluginSettingsArgs[0]) require.Equal(t, int64(0), ctx.pluginSettingsService.getPluginSettingsArgs[0])
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs) require.Empty(t, ctx.importDashboardArgs)
}) })
@ -33,17 +35,17 @@ func TestService(t *testing.T) {
Enabled: false, Enabled: false,
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
PluginId: "test", PluginId: "test",
Path: "dashboard.json", Reference: "dashboard.json",
}, },
}, },
}, func(ctx *scenarioContext) { }, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards() ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs) require.Empty(t, ctx.importDashboardArgs)
}) })
@ -55,17 +57,17 @@ func TestService(t *testing.T) {
Enabled: true, Enabled: true,
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
PluginId: "test", PluginId: "test",
Path: "dashboard.json", Reference: "dashboard.json",
}, },
}, },
}, func(ctx *scenarioContext) { }, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards() ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs) require.Empty(t, ctx.importDashboardArgs)
}) })
@ -87,17 +89,17 @@ func TestService(t *testing.T) {
}, },
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
PluginId: "test", PluginId: "test",
Path: "dashboard.json", Reference: "dashboard.json",
}, },
}, },
}, func(ctx *scenarioContext) { }, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards() ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs) require.Empty(t, ctx.importDashboardArgs)
}) })
@ -119,20 +121,20 @@ func TestService(t *testing.T) {
}, },
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
PluginId: "test", PluginId: "test",
Path: "dashboard.json", Reference: "dashboard.json",
Removed: false, Removed: false,
Revision: 1, Revision: 1,
ImportedRevision: 1, ImportedRevision: 1,
}, },
}, },
}, func(ctx *scenarioContext) { }, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards() ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs) require.Empty(t, ctx.importDashboardArgs)
}) })
@ -156,33 +158,33 @@ func TestService(t *testing.T) {
}, },
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
DashboardId: 3, DashboardId: 3,
PluginId: "test", PluginId: "test",
Path: "removed.json", Reference: "removed.json",
Removed: true, Removed: true,
}, },
{ {
DashboardId: 4, DashboardId: 4,
PluginId: "test", PluginId: "test",
Path: "not-updated.json", Reference: "not-updated.json",
}, },
{ {
DashboardId: 5, DashboardId: 5,
PluginId: "test", PluginId: "test",
Path: "updated.json", Reference: "updated.json",
Revision: 1, Revision: 1,
ImportedRevision: 2, ImportedRevision: 2,
}, },
}, },
}, func(ctx *scenarioContext) { }, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards() ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Len(t, ctx.deleteDashboardArgs, 1) require.Len(t, ctx.dashboardService.deleteDashboardArgs, 1)
require.Equal(t, int64(2), ctx.deleteDashboardArgs[0].OrgId) require.Equal(t, int64(2), ctx.dashboardService.deleteDashboardArgs[0].orgId)
require.Equal(t, int64(3), ctx.deleteDashboardArgs[0].Id) require.Equal(t, int64(3), ctx.dashboardService.deleteDashboardArgs[0].dashboardId)
require.Len(t, ctx.importDashboardArgs, 1) require.Len(t, ctx.importDashboardArgs, 1)
require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId) require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId)
@ -204,10 +206,10 @@ func TestService(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, ctx.getDashboardsByPluginIdQueryArgs, 1) require.Len(t, ctx.dashboardPluginService.args, 1)
require.Equal(t, int64(2), ctx.getDashboardsByPluginIdQueryArgs[0].OrgId) require.Equal(t, int64(2), ctx.dashboardPluginService.args[0].OrgId)
require.Equal(t, "test", ctx.getDashboardsByPluginIdQueryArgs[0].PluginId) require.Equal(t, "test", ctx.dashboardPluginService.args[0].PluginId)
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
}) })
}) })
@ -227,21 +229,21 @@ func TestService(t *testing.T) {
}, },
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
DashboardId: 3, DashboardId: 3,
PluginId: "test", PluginId: "test",
Path: "dashboard1.json", Reference: "dashboard1.json",
}, },
{ {
DashboardId: 4, DashboardId: 4,
PluginId: "test", PluginId: "test",
Path: "dashboard2.json", Reference: "dashboard2.json",
}, },
{ {
DashboardId: 5, DashboardId: 5,
PluginId: "test", PluginId: "test",
Path: "dashboard3.json", Reference: "dashboard3.json",
}, },
}, },
}, func(ctx *scenarioContext) { }, func(ctx *scenarioContext) {
@ -252,10 +254,10 @@ func TestService(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
require.Len(t, ctx.getDashboardsByPluginIdQueryArgs, 1) require.Len(t, ctx.dashboardPluginService.args, 1)
require.Equal(t, int64(2), ctx.getDashboardsByPluginIdQueryArgs[0].OrgId) require.Equal(t, int64(2), ctx.dashboardPluginService.args[0].OrgId)
require.Equal(t, "test", ctx.getDashboardsByPluginIdQueryArgs[0].PluginId) require.Equal(t, "test", ctx.dashboardPluginService.args[0].PluginId)
require.Len(t, ctx.deleteDashboardArgs, 3) require.Len(t, ctx.dashboardService.deleteDashboardArgs, 3)
}) })
scenario(t, "When app plugin is enabled, stored disabled plugin and with dashboard updates should import dashboards", scenario(t, "When app plugin is enabled, stored disabled plugin and with dashboard updates should import dashboards",
@ -278,25 +280,25 @@ func TestService(t *testing.T) {
}, },
}, },
}, },
pluginDashboards: []*plugins.PluginDashboardInfoDTO{ pluginDashboards: []*plugindashboards.PluginDashboard{
{ {
DashboardId: 3, DashboardId: 3,
PluginId: "test", PluginId: "test",
Path: "dashboard1.json", Reference: "dashboard1.json",
Revision: 1, Revision: 1,
ImportedRevision: 0, ImportedRevision: 0,
}, },
{ {
DashboardId: 4, DashboardId: 4,
PluginId: "test", PluginId: "test",
Path: "dashboard2.json", Reference: "dashboard2.json",
Revision: 1, Revision: 1,
ImportedRevision: 0, ImportedRevision: 0,
}, },
{ {
DashboardId: 5, DashboardId: 5,
PluginId: "test", PluginId: "test",
Path: "dashboard3.json", Reference: "dashboard3.json",
Revision: 1, Revision: 1,
ImportedRevision: 0, ImportedRevision: 0,
}, },
@ -309,7 +311,7 @@ func TestService(t *testing.T) {
}) })
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, ctx.deleteDashboardArgs) require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Len(t, ctx.importDashboardArgs, 3) require.Len(t, ctx.importDashboardArgs, 3)
require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId) require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId)
@ -348,23 +350,24 @@ func (m *pluginStoreMock) Plugin(ctx context.Context, pluginID string) (plugins.
return plugins.PluginDTO{}, false return plugins.PluginDTO{}, false
} }
type pluginDashboardManagerMock struct { type pluginDashboardServiceMock struct {
plugins.PluginDashboardManager listPluginDashboardsFunc func(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error)
getPluginDashboardsFunc func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) loadPluginDashboardfunc func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error)
loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error)
} }
func (m *pluginDashboardManagerMock) GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { func (m *pluginDashboardServiceMock) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
if m.getPluginDashboardsFunc != nil { if m.listPluginDashboardsFunc != nil {
return m.getPluginDashboardsFunc(ctx, orgID, pluginID) return m.listPluginDashboardsFunc(ctx, req)
} }
return []*plugins.PluginDashboardInfoDTO{}, nil return &plugindashboards.ListPluginDashboardsResponse{
Items: []*plugindashboards.PluginDashboard{},
}, nil
} }
func (m *pluginDashboardManagerMock) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { func (m *pluginDashboardServiceMock) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
if m.loadPluginDashboardFunc != nil { if m.loadPluginDashboardfunc != nil {
return m.loadPluginDashboardFunc(ctx, pluginID, path) return m.loadPluginDashboardfunc(ctx, req)
} }
return nil, nil return nil, nil
@ -418,38 +421,55 @@ func (s *pluginsSettingsServiceMock) UpdatePluginSetting(_ context.Context, _ *m
return s.err return s.err
} }
type dashboardServiceMock struct {
dashboards.DashboardService
deleteDashboardArgs []struct {
orgId int64
dashboardId int64
}
}
func (s *dashboardServiceMock) DeleteDashboard(_ context.Context, dashboardId int64, orgId int64) error {
s.deleteDashboardArgs = append(s.deleteDashboardArgs, struct {
orgId int64
dashboardId int64
}{
orgId: orgId,
dashboardId: dashboardId,
})
return nil
}
type scenarioInput struct { type scenarioInput struct {
storedPluginSettings []*models.PluginSettingInfoDTO storedPluginSettings []*models.PluginSettingInfoDTO
installedPlugins []plugins.PluginDTO installedPlugins []plugins.PluginDTO
pluginDashboards []*plugins.PluginDashboardInfoDTO pluginDashboards []*plugindashboards.PluginDashboard
} }
type scenarioContext struct { type scenarioContext struct {
t *testing.T t *testing.T
bus bus.Bus bus bus.Bus
pluginSettingsService *pluginsSettingsServiceMock pluginSettingsService *pluginsSettingsServiceMock
pluginStore plugins.Store pluginStore plugins.Store
pluginDashboardManager plugins.PluginDashboardManager pluginDashboardService plugindashboards.Service
importDashboardService dashboardimport.Service importDashboardService dashboardimport.Service
importDashboardArgs []*dashboardimport.ImportDashboardRequest dashboardPluginService *dashboardPluginServiceMock
deleteDashboardArgs []*models.DeleteDashboardCommand dashboardService *dashboardServiceMock
getPluginSettingsByIdArgs []*models.GetPluginSettingByIdQuery importDashboardArgs []*dashboardimport.ImportDashboardRequest
updatePluginSettingVersionArgs []*models.UpdatePluginSettingVersionCmd getPluginSettingsByIdArgs []*models.GetPluginSettingByIdQuery
getDashboardsByPluginIdQueryArgs []*models.GetDashboardsByPluginIdQuery updatePluginSettingVersionArgs []*models.UpdatePluginSettingVersionCmd
s *Service dashboardUpdater *DashboardUpdater
} }
func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenarioContext)) { func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenarioContext)) {
t.Helper() t.Helper()
sCtx := &scenarioContext{ sCtx := &scenarioContext{
t: t, t: t,
bus: bus.New(), bus: bus.New(),
importDashboardArgs: []*dashboardimport.ImportDashboardRequest{}, importDashboardArgs: []*dashboardimport.ImportDashboardRequest{},
deleteDashboardArgs: []*models.DeleteDashboardCommand{}, getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{},
getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{}, updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{},
updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{},
getDashboardsByPluginIdQueryArgs: []*models.GetDashboardsByPluginIdQuery{},
} }
getPlugin := func(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) { getPlugin := func(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) {
@ -470,31 +490,57 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar
pluginFunc: getPlugin, pluginFunc: getPlugin,
} }
getPluginDashboards := func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { pluginDashboards := map[string][]*models.Dashboard{}
dashboards := []*plugins.PluginDashboardInfoDTO{} for _, pluginDashboard := range input.pluginDashboards {
if _, exists := pluginDashboards[pluginDashboard.PluginId]; !exists {
pluginDashboards[pluginDashboard.PluginId] = []*models.Dashboard{}
}
pluginDashboards[pluginDashboard.PluginId] = append(pluginDashboards[pluginDashboard.PluginId], &models.Dashboard{
PluginId: pluginDashboard.PluginId,
})
}
sCtx.dashboardPluginService = &dashboardPluginServiceMock{
pluginDashboards: pluginDashboards,
}
sCtx.dashboardService = &dashboardServiceMock{
deleteDashboardArgs: []struct {
orgId int64
dashboardId int64
}{},
}
listPluginDashboards := func(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
dashboards := []*plugindashboards.PluginDashboard{}
for _, d := range input.pluginDashboards { for _, d := range input.pluginDashboards {
if d.PluginId == pluginID { if d.PluginId == req.PluginID {
dashboards = append(dashboards, d) dashboards = append(dashboards, d)
} }
} }
return dashboards, nil return &plugindashboards.ListPluginDashboardsResponse{
Items: dashboards,
}, nil
} }
loadPluginDashboard := func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { loadPluginDashboard := func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
for _, d := range input.pluginDashboards { for _, d := range input.pluginDashboards {
if d.PluginId == pluginID && path == d.Path { if d.PluginId == req.PluginID && req.Reference == d.Reference {
return &models.Dashboard{}, nil return &plugindashboards.LoadPluginDashboardResponse{
Dashboard: &models.Dashboard{},
}, nil
} }
} }
return nil, fmt.Errorf("no match for loading plugin dashboard") return nil, fmt.Errorf("no match for loading plugin dashboard")
} }
sCtx.pluginDashboardManager = &pluginDashboardManagerMock{ sCtx.pluginDashboardService = &pluginDashboardServiceMock{
getPluginDashboardsFunc: getPluginDashboards, listPluginDashboardsFunc: listPluginDashboards,
loadPluginDashboardFunc: loadPluginDashboard, loadPluginDashboardfunc: loadPluginDashboard,
} }
importDashboard := func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { importDashboard := func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) {
@ -509,43 +555,15 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar
importDashboardFunc: importDashboard, importDashboardFunc: importDashboard,
} }
sCtx.bus.AddHandler(func(ctx context.Context, cmd *models.DeleteDashboardCommand) error { sCtx.dashboardUpdater = newDashboardUpdater(
sCtx.deleteDashboardArgs = append(sCtx.deleteDashboardArgs, cmd) sCtx.bus,
sCtx.pluginStore,
return nil sCtx.pluginDashboardService,
}) sCtx.importDashboardService,
sCtx.pluginSettingsService,
sCtx.bus.AddHandler(func(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { sCtx.dashboardPluginService,
sCtx.getDashboardsByPluginIdQueryArgs = append(sCtx.getDashboardsByPluginIdQueryArgs, query) sCtx.dashboardService,
dashboards := []*models.Dashboard{} )
var plugin *models.PluginSettingInfoDTO
for _, p := range input.storedPluginSettings {
if p.PluginId == query.PluginId {
plugin = p
}
}
if plugin == nil {
return nil
}
for _, d := range input.pluginDashboards {
if d.PluginId == plugin.PluginId {
dashboards = append(dashboards, &models.Dashboard{
Id: d.DashboardId,
OrgId: plugin.OrgId,
})
}
}
query.Result = dashboards
return nil
})
sCtx.s = newService(sCtx.bus, sCtx.pluginStore, sCtx.pluginDashboardManager, sCtx.importDashboardService, sCtx.pluginSettingsService)
t.Cleanup(bus.ClearBusHandlers) t.Cleanup(bus.ClearBusHandlers)

View File

@ -0,0 +1,134 @@
package service
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/plugindashboards"
)
func ProvideService(pluginDashboardStore plugins.DashboardFileStore, dashboardPluginService dashboards.PluginService) *Service {
return &Service{
pluginDashboardStore: pluginDashboardStore,
dashboardPluginService: dashboardPluginService,
logger: log.New("plugindashboards"),
}
}
type Service struct {
pluginDashboardStore plugins.DashboardFileStore
dashboardPluginService dashboards.PluginService
logger log.Logger
}
func (s Service) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
if req == nil {
return nil, fmt.Errorf("req cannot be nil")
}
listArgs := &plugins.ListPluginDashboardFilesArgs{
PluginID: req.PluginID,
}
listResp, err := s.pluginDashboardStore.ListPluginDashboardFiles(ctx, listArgs)
if err != nil {
return nil, err
}
result := make([]*plugindashboards.PluginDashboard, 0)
// load current dashboards
query := models.GetDashboardsByPluginIdQuery{OrgId: req.OrgID, PluginId: req.PluginID}
if err := s.dashboardPluginService.GetDashboardsByPluginID(ctx, &query); err != nil {
return nil, err
}
existingMatches := make(map[int64]bool)
for _, reference := range listResp.FileReferences {
loadReq := &plugindashboards.LoadPluginDashboardRequest{
PluginID: req.PluginID,
Reference: reference,
}
loadResp, err := s.LoadPluginDashboard(ctx, loadReq)
if err != nil {
return nil, err
}
dashboard := loadResp.Dashboard
res := &plugindashboards.PluginDashboard{}
res.UID = dashboard.Uid
res.Reference = reference
res.PluginId = req.PluginID
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
break
}
}
result = append(result, res)
}
// find deleted dashboards
for _, dash := range query.Result {
if _, exists := existingMatches[dash.Id]; !exists {
result = append(result, &plugindashboards.PluginDashboard{
UID: dash.Uid,
Slug: dash.Slug,
DashboardId: dash.Id,
Removed: true,
})
}
}
return &plugindashboards.ListPluginDashboardsResponse{
Items: result,
}, nil
}
func (s Service) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
if req == nil {
return nil, fmt.Errorf("req cannot be nil")
}
args := &plugins.GetPluginDashboardFileContentsArgs{
PluginID: req.PluginID,
FileReference: req.Reference,
}
resp, err := s.pluginDashboardStore.GetPluginDashboardFileContents(ctx, args)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Content.Close(); err != nil {
s.logger.Warn("Failed to close plugin dashboard file", "reference", req.Reference, "err", err)
}
}()
data, err := simplejson.NewFromReader(resp.Content)
if err != nil {
return nil, err
}
return &plugindashboards.LoadPluginDashboardResponse{
Dashboard: models.NewDashboardFromJson(data),
}, nil
}
var _ plugindashboards.Service = &Service{}

View File

@ -0,0 +1,224 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"sort"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/stretchr/testify/require"
)
func TestGetPluginDashboards(t *testing.T) {
testDashboardOld := simplejson.New()
testDashboardOld.Set("title", "Nginx Connections")
testDashboardOld.Set("revision", 22)
testDashboardNew := simplejson.New()
testDashboardNew.Set("title", "Nginx Connections")
testDashboardNew.Set("revision", 23)
testDashboardNewBytes, err := testDashboardNew.MarshalJSON()
require.NoError(t, err)
testDashboardDeleted := simplejson.New()
testDashboardDeleted.Set("title", "test")
testDashboardDeleted.Set("id", 4)
pluginDashboardStore := &pluginDashboardStoreMock{
pluginDashboardFiles: map[string]map[string][]byte{
"test-app": {
"nginx-connections": testDashboardNewBytes,
},
},
}
dashboardPluginService := &dashboardPluginServiceMock{
pluginDashboards: map[string][]*models.Dashboard{
"test-app": {
models.NewDashboardFromJson(testDashboardOld),
models.NewDashboardFromJson(testDashboardDeleted),
},
},
}
s := ProvideService(pluginDashboardStore, dashboardPluginService)
require.NotNil(t, s)
t.Run("LoadPluginDashboard", func(t *testing.T) {
testCases := []struct {
desc string
req *plugindashboards.LoadPluginDashboardRequest
errorFn require.ErrorAssertionFunc
respValueFn require.ValueAssertionFunc
validateFn func(tt *testing.T, resp *plugindashboards.LoadPluginDashboardResponse)
}{
{
desc: "Should return error for nil req",
req: nil,
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return error for non-existing plugin",
req: &plugindashboards.LoadPluginDashboardRequest{
PluginID: "non-existing",
},
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return error for non-existing file reference",
req: &plugindashboards.LoadPluginDashboardRequest{
PluginID: "test-app",
Reference: "non-existing",
},
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return expected loaded dashboard model",
req: &plugindashboards.LoadPluginDashboardRequest{
PluginID: "test-app",
Reference: "nginx-connections",
},
errorFn: require.NoError,
respValueFn: require.NotNil,
validateFn: func(tt *testing.T, resp *plugindashboards.LoadPluginDashboardResponse) {
require.NotNil(tt, resp.Dashboard)
require.Equal(tt, testDashboardNew.Get("title").MustString(), resp.Dashboard.Title)
require.Equal(tt, testDashboardNew.Get("revision").MustInt64(1), resp.Dashboard.Data.Get("revision").MustInt64(1))
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
resp, err := s.LoadPluginDashboard(context.Background(), tc.req)
tc.errorFn(t, err)
tc.respValueFn(t, resp)
if resp != nil {
tc.validateFn(t, resp)
}
})
}
})
t.Run("ListPluginDashboards", func(t *testing.T) {
testCases := []struct {
desc string
req *plugindashboards.ListPluginDashboardsRequest
errorFn require.ErrorAssertionFunc
respValueFn require.ValueAssertionFunc
validateFn func(tt *testing.T, resp *plugindashboards.ListPluginDashboardsResponse)
}{
{
desc: "Should return error for nil req",
req: nil,
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return error for non-existing plugin",
req: &plugindashboards.ListPluginDashboardsRequest{
PluginID: "non-existing",
},
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return updated nginx dashboard revision and removed title dashboard",
req: &plugindashboards.ListPluginDashboardsRequest{
PluginID: "test-app",
},
errorFn: require.NoError,
respValueFn: require.NotNil,
validateFn: func(tt *testing.T, resp *plugindashboards.ListPluginDashboardsResponse) {
require.Len(tt, resp.Items, 2)
nginx := resp.Items[0]
require.True(tt, nginx.Imported)
require.Equal(t, int64(23), nginx.Revision)
require.Equal(t, int64(22), nginx.ImportedRevision)
require.Equal(tt, testDashboardOld.Get("title").MustString(), nginx.Title)
test := resp.Items[1]
require.True(tt, test.Removed)
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
resp, err := s.ListPluginDashboards(context.Background(), tc.req)
tc.errorFn(t, err)
tc.respValueFn(t, resp)
if resp != nil {
tc.validateFn(t, resp)
}
})
}
})
}
type pluginDashboardStoreMock struct {
pluginDashboardFiles map[string]map[string][]byte
}
func (m pluginDashboardStoreMock) ListPluginDashboardFiles(ctx context.Context, args *plugins.ListPluginDashboardFilesArgs) (*plugins.ListPluginDashboardFilesResult, error) {
if dashboardFiles, exists := m.pluginDashboardFiles[args.PluginID]; exists {
references := []string{}
for ref := range dashboardFiles {
references = append(references, ref)
}
sort.Strings(references)
return &plugins.ListPluginDashboardFilesResult{
FileReferences: references,
}, nil
}
return nil, plugins.NotFoundError{PluginID: args.PluginID}
}
func (m pluginDashboardStoreMock) GetPluginDashboardFileContents(ctx context.Context, args *plugins.GetPluginDashboardFileContentsArgs) (*plugins.GetPluginDashboardFileContentsResult, error) {
if dashboardFiles, exists := m.pluginDashboardFiles[args.PluginID]; exists {
if content, exists := dashboardFiles[args.FileReference]; exists {
r := bytes.NewReader(content)
return &plugins.GetPluginDashboardFileContentsResult{
Content: io.NopCloser(r),
}, nil
}
} else if !exists {
return nil, plugins.NotFoundError{PluginID: args.PluginID}
}
return nil, fmt.Errorf("plugin dashboard file not found")
}
type dashboardPluginServiceMock struct {
pluginDashboards map[string][]*models.Dashboard
args []*models.GetDashboardsByPluginIdQuery
}
func (d *dashboardPluginServiceMock) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
query.Result = []*models.Dashboard{}
if dashboards, exists := d.pluginDashboards[query.PluginId]; exists {
query.Result = dashboards
}
if d.args == nil {
d.args = []*models.GetDashboardsByPluginIdQuery{}
}
d.args = append(d.args, query)
return nil
}

View File

@ -39,7 +39,6 @@ func (ss *SQLStore) addDashboardQueryAndCommandHandlers() {
bus.AddHandler("sql", ss.GetDashboards) bus.AddHandler("sql", ss.GetDashboards)
bus.AddHandler("sql", ss.HasEditPermissionInFolders) bus.AddHandler("sql", ss.HasEditPermissionInFolders)
bus.AddHandler("sql", ss.GetDashboardPermissionsForUser) bus.AddHandler("sql", ss.GetDashboardPermissionsForUser)
bus.AddHandler("sql", ss.GetDashboardsByPluginId)
bus.AddHandler("sql", ss.GetDashboardSlugById) bus.AddHandler("sql", ss.GetDashboardSlugById)
bus.AddHandler("sql", ss.HasAdminPermissionInFolders) bus.AddHandler("sql", ss.HasAdminPermissionInFolders)
} }
@ -441,17 +440,6 @@ func (ss *SQLStore) GetDashboardPermissionsForUser(ctx context.Context, query *m
}) })
} }
func (ss *SQLStore) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
var dashboards = make([]*models.Dashboard, 0)
whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)
err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards)
query.Result = dashboards
return err
})
}
type DashboardSlugDTO struct { type DashboardSlugDTO struct {
Slug string Slug string
} }

View File

@ -660,10 +660,6 @@ func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query
return m.ExpectedError return m.ExpectedError
} }
func (m *SQLStoreMock) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return m.ExpectedError
}
func (m *SQLStoreMock) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error { func (m *SQLStoreMock) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error {
return m.ExpectedError return m.ExpectedError
} }

View File

@ -150,6 +150,5 @@ type Store interface {
SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error
HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error
GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error
GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error
} }

View File

@ -14,8 +14,8 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testinfra"
@ -61,7 +61,7 @@ func TestDashboardQuota(t *testing.T) {
}) })
b, err := ioutil.ReadAll(resp.Body) b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err) require.NoError(t, err)
dashboardDTO := &plugins.PluginDashboardInfoDTO{} dashboardDTO := &plugindashboards.PluginDashboard{}
err = json.Unmarshal(b, dashboardDTO) err = json.Unmarshal(b, dashboardDTO)
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, 1, dashboardDTO.DashboardId) require.EqualValues(t, 1, dashboardDTO.DashboardId)

View File

@ -49,6 +49,16 @@ func NewServer(t testing.TB, routeRegister routing.RouteRegister) *Server {
} }
} }
// NewGetRequest creates a new GET request setup for test.
func (s *Server) NewGetRequest(target string) *http.Request {
return s.NewRequest(http.MethodGet, target, nil)
}
// NewPostRequest creates a new POST request setup for test.
func (s *Server) NewPostRequest(target string, body io.Reader) *http.Request {
return s.NewRequest(http.MethodPost, target, body)
}
// NewRequest creates a new request setup for test. // NewRequest creates a new request setup for test.
func (s *Server) NewRequest(method string, target string, body io.Reader) *http.Request { func (s *Server) NewRequest(method string, target string, body io.Reader) *http.Request {
s.t.Helper() s.t.Helper()
@ -65,11 +75,19 @@ func (s *Server) NewRequest(method string, target string, body io.Reader) *http.
return req return req
} }
// Send sends an HTTP request to the test server and returns an HTTP response // Send sends a HTTP request to the test server and returns an HTTP response.
func (s *Server) Send(req *http.Request) (*http.Response, error) { func (s *Server) Send(req *http.Request) (*http.Response, error) {
return http.DefaultClient.Do(req) return http.DefaultClient.Do(req)
} }
// SendJSON sets the Content-Type header to application/json and sends
// a HTTP request to the test server and returns an HTTP response.
// Suitable for POST/PUT/PATCH requests that sends request body as JSON.
func (s *Server) SendJSON(req *http.Request) (*http.Response, error) {
req.Header.Add("Content-Type", "application/json")
return s.Send(req)
}
func generateRequestIdentifier() string { func generateRequestIdentifier() string {
return uuid.NewString() return uuid.NewString()
} }
@ -91,7 +109,7 @@ func RequestWithWebContext(req *http.Request, c *models.ReqContext) *http.Reques
func RequestWithSignedInUser(req *http.Request, user *models.SignedInUser) *http.Request { func RequestWithSignedInUser(req *http.Request, user *models.SignedInUser) *http.Request {
return RequestWithWebContext(req, &models.ReqContext{ return RequestWithWebContext(req, &models.ReqContext{
SignedInUser: &models.SignedInUser{}, SignedInUser: user,
IsSignedIn: true, IsSignedIn: true,
}) })
} }

View File

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/api/response"
@ -12,6 +13,69 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestServer(t *testing.T) {
routeRegister := routing.NewRouteRegister()
var actualRequest *http.Request
routeRegister.Post("/api", routing.Wrap(func(c *models.ReqContext) response.Response {
actualRequest = c.Req
return response.JSON(http.StatusOK, c.SignedInUser)
}))
s := NewServer(t, routeRegister)
require.NotNil(t, s)
t.Run("NewRequest: GET api should set expected properties", func(t *testing.T) {
req := s.NewRequest(http.MethodGet, "api", nil)
verifyRequest(t, s, req, "")
})
t.Run("NewGetRequest: GET /api should set expected properties", func(t *testing.T) {
req := s.NewGetRequest("/api")
verifyRequest(t, s, req, "")
})
t.Run("NewPostRequest: POST api should set expected properties", func(t *testing.T) {
payload := strings.NewReader("test")
req := s.NewPostRequest("api", payload)
verifyRequest(t, s, req, "test")
t.Run("SendJSON should set expected Content-Type header", func(t *testing.T) {
payload.Reset("test")
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, resp.Body.Close())
require.NotNil(t, actualRequest)
require.Equal(t, "application/json", actualRequest.Header.Get("Content-Type"))
})
})
}
func verifyRequest(t *testing.T, s *Server, req *http.Request, expectedBody string) {
require.NotNil(t, req)
require.Equal(t, s.TestServer.URL+"/api", req.URL.String())
if expectedBody == "" {
require.Equal(t, http.MethodGet, req.Method)
require.Equal(t, http.NoBody, req.Body)
} else {
require.Equal(t, http.MethodPost, req.Method)
require.NotNil(t, req.Body)
bytes, err := ioutil.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, expectedBody, string(bytes))
}
require.NotEmpty(t, requestIdentifierFromRequest(req))
req = RequestWithWebContext(req, &models.ReqContext{
IsSignedIn: true,
})
require.NotNil(t, req)
ctx := requestContextFromRequest(req)
require.NotNil(t, ctx)
require.True(t, ctx.IsSignedIn)
}
func TestServerClient(t *testing.T) { func TestServerClient(t *testing.T) {
routeRegister := routing.NewRouteRegister() routeRegister := routing.NewRouteRegister()
routeRegister.Get("/test", routing.Wrap(func(c *models.ReqContext) response.Response { routeRegister.Get("/test", routing.Wrap(func(c *models.ReqContext) response.Response {