diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 79abfa8845a..15b32a83a22 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -40,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" + "github.com/grafana/grafana/pkg/web/webtest" "github.com/stretchr/testify/require" ) @@ -446,3 +447,31 @@ func mockRequestBody(v interface{}) io.ReadCloser { b, _ := json.Marshal(v) 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 +} diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index db2a4691ea1..e2823d6456b 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -48,6 +48,7 @@ import ( "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/notifications" + "github.com/grafana/grafana/pkg/services/plugindashboards" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/query" @@ -100,7 +101,7 @@ type HTTPServer struct { PluginRequestValidator models.PluginRequestValidator pluginClient plugins.Client pluginStore plugins.Store - pluginDashboardManager plugins.PluginDashboardManager + pluginDashboardService plugindashboards.Service pluginStaticRouteResolver plugins.StaticRouteResolver pluginErrorResolver plugins.ErrorResolver 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, cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine, 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, dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service, @@ -191,7 +192,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginClient: pluginClient, pluginStore: pluginStore, pluginStaticRouteResolver: pluginStaticRouteResolver, - pluginDashboardManager: pluginDashboardManager, + pluginDashboardService: pluginDashboardService, pluginErrorResolver: pluginErrorResolver, grafanaUpdateChecker: grafanaUpdateChecker, pluginsUpdateChecker: pluginsUpdateChecker, diff --git a/pkg/api/plugin_dashboards.go b/pkg/api/plugin_dashboards.go new file mode 100644 index 00000000000..87ebb4eb79b --- /dev/null +++ b/pkg/api/plugin_dashboards.go @@ -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, ¬Found) { + 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) +} diff --git a/pkg/api/plugin_dashboards_test.go b/pkg/api/plugin_dashboards_test.go new file mode 100644 index 00000000000..38217c00394 --- /dev/null +++ b/pkg/api/plugin_dashboards_test.go @@ -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} +} diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 8c00db77126..0593553e6df 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -182,22 +182,6 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Respons 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, ¬Found) { - 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 { pluginID := web.Params(c.Req)[":pluginId"] name := web.Params(c.Req)[":name"] diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index 105bdfb9205..c10283b0031 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -2,10 +2,10 @@ package plugins import ( "context" + "io" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/backendplugin" ) @@ -73,26 +73,32 @@ type PluginLoaderAuthorizer interface { CanLoadPlugin(plugin *Plugin) bool } -type PluginDashboardInfoDTO struct { - UID string `json:"uid"` - PluginId string `json:"pluginId"` - 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"` +// ListPluginDashboardFilesArgs list plugin dashboard files argument model. +type ListPluginDashboardFilesArgs struct { + PluginID string } -type PluginDashboardManager interface { - // GetPluginDashboards gets dashboards for a certain org/plugin. - GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*PluginDashboardInfoDTO, error) - // LoadPluginDashboard loads a plugin dashboard. - LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) +// GetPluginDashboardFilesArgs list plugin dashboard files result model. +type ListPluginDashboardFilesResult struct { + FileReferences []string +} + +// 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) } diff --git a/pkg/plugins/manager/dashboard_file_store.go b/pkg/plugins/manager/dashboard_file_store.go new file mode 100644 index 00000000000..009bd0a2aa8 --- /dev/null +++ b/pkg/plugins/manager/dashboard_file_store.go @@ -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{} diff --git a/pkg/plugins/manager/dashboard_file_store_test.go b/pkg/plugins/manager/dashboard_file_store_test.go new file mode 100644 index 00000000000..0c32a5bdee4 --- /dev/null +++ b/pkg/plugins/manager/dashboard_file_store_test.go @@ -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", + }, + }, + }, + }, + }, + } +} diff --git a/pkg/plugins/manager/dashboards.go b/pkg/plugins/manager/dashboards.go deleted file mode 100644 index a90a8125ac6..00000000000 --- a/pkg/plugins/manager/dashboards.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/manager/dashboards_test.go b/pkg/plugins/manager/dashboards_test.go deleted file mode 100644 index 55397f0bff2..00000000000 --- a/pkg/plugins/manager/dashboards_test.go +++ /dev/null @@ -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) -} diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index 5e2a4ef1cf6..bfb616f5130 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -24,7 +24,6 @@ const ( var _ plugins.Client = (*PluginManager)(nil) var _ plugins.Store = (*PluginManager)(nil) -var _ plugins.PluginDashboardManager = (*PluginManager)(nil) var _ plugins.StaticRouteResolver = (*PluginManager)(nil) var _ plugins.RendererManager = (*PluginManager)(nil) diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 58028e80448..4ec2356c23d 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/live/pushhttp" "github.com/grafana/grafana/pkg/services/ngalert" "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/rendering" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -35,8 +35,9 @@ func ProvideBackgroundServiceRegistry( metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService, remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service, // Need to make sure these are initialized, is there a better place to put them? - _ *plugindashboards.Service, _ *dashboardsnapshots.Service, - _ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider, + _ *dashboardsnapshots.Service, _ *alerting.AlertNotificationService, + _ serviceaccounts.Service, _ *guardian.Provider, + _ *plugindashboardsservice.DashboardUpdater, ) *BackgroundServiceRegistry { return NewBackgroundServiceRegistry( httpServer, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 2fc15c7cb07..f3e4ed7e578 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -58,6 +58,7 @@ import ( "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/services/plugindashboards" + plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service" "github.com/grafana/grafana/pkg/services/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" "github.com/grafana/grafana/pkg/services/query" @@ -126,8 +127,8 @@ var wireBasicSet = wire.NewSet( manager.ProvideService, wire.Bind(new(plugins.Client), 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.PluginDashboardManager), new(*manager.PluginManager)), wire.Bind(new(plugins.RendererManager), new(*manager.PluginManager)), coreplugin.ProvideCoreRegistry, loader.ProvideService, @@ -212,11 +213,14 @@ var wireBasicSet = wire.NewSet( dashboardstore.ProvideDashboardStore, wire.Bind(new(dashboards.DashboardService), 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.Store), new(*dashboardstore.DashboardStore)), dashboardimportservice.ProvideService, wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)), - plugindashboards.ProvideService, + plugindashboardsservice.ProvideService, + wire.Bind(new(plugindashboards.Service), new(*plugindashboardsservice.Service)), + plugindashboardsservice.ProvideDashboardUpdater, alerting.ProvideDashAlertExtractorService, wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)), comments.ProvideService, diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go index 539ecb8f5e1..37b84a94456 100644 --- a/pkg/services/dashboardimport/api/api_test.go +++ b/pkg/services/dashboardimport/api/api_test.go @@ -43,9 +43,8 @@ func TestImportDashboardAPI(t *testing.T) { cmd := &dashboardimport.ImportDashboardRequest{} jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) - req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) - req.Header.Add("Content-Type", "application/json") - resp, err := s.Send(req) + req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes)) + resp, err := s.SendJSON(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) @@ -58,12 +57,11 @@ func TestImportDashboardAPI(t *testing.T) { } jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) - req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) - req.Header.Add("Content-Type", "application/json") + req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes)) webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) - resp, err := s.Send(req) + resp, err := s.SendJSON(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) @@ -75,12 +73,11 @@ func TestImportDashboardAPI(t *testing.T) { } jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) - req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) - req.Header.Add("Content-Type", "application/json") + req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes)) webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) - resp, err := s.Send(req) + resp, err := s.SendJSON(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -93,12 +90,11 @@ func TestImportDashboardAPI(t *testing.T) { } jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) - req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) - req.Header.Add("Content-Type", "application/json") + req := s.NewPostRequest("/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) - resp, err := s.Send(req) + resp, err := s.SendJSON(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -136,12 +132,11 @@ func TestImportDashboardAPI(t *testing.T) { } jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) - req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) - req.Header.Add("Content-Type", "application/json") + req := s.NewPostRequest("/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) - resp, err := s.Send(req) + resp, err := s.SendJSON(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -165,12 +160,11 @@ func TestImportDashboardAPI(t *testing.T) { } jsonBytes, err := json.Marshal(cmd) require.NoError(t, err) - req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) - req.Header.Add("Content-Type", "application/json") + req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes)) webtest.RequestWithSignedInUser(req, &models.SignedInUser{ UserId: 1, }) - resp, err := s.Send(req) + resp, err := s.SendJSON(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusForbidden, resp.StatusCode) diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go index cceaaef84a0..7772a1ce4a3 100644 --- a/pkg/services/dashboardimport/service/service.go +++ b/pkg/services/dashboardimport/service/service.go @@ -12,18 +12,19 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardimport/utils" "github.com/grafana/grafana/pkg/services/dashboards" "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/schemaloader" ) func ProvideService(routeRegister routing.RouteRegister, quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService, - pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, + pluginDashboardService plugindashboards.Service, pluginStore plugins.Store, libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService, ac accesscontrol.AccessControl, ) *ImportDashboardService { s := &ImportDashboardService{ - pluginDashboardManager: pluginDashboardManager, + pluginDashboardService: pluginDashboardService, dashboardService: dashboardService, libraryPanelService: libraryPanelService, } @@ -35,7 +36,7 @@ func ProvideService(routeRegister routing.RouteRegister, } type ImportDashboardService struct { - pluginDashboardManager plugins.PluginDashboardManager + pluginDashboardService plugindashboards.Service dashboardService dashboards.DashboardService libraryPanelService librarypanels.Service } @@ -43,9 +44,14 @@ type ImportDashboardService struct { func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { var dashboard *models.Dashboard if req.PluginId != "" { - var err error - if dashboard, err = s.pluginDashboardManager.LoadPluginDashboard(ctx, req.PluginId, req.Path); err != nil { + loadReq := &plugindashboards.LoadPluginDashboardRequest{ + PluginID: req.PluginId, + Reference: req.Path, + } + if resp, err := s.pluginDashboardService.LoadPluginDashboard(ctx, loadReq); err != nil { return nil, err + } else { + dashboard = resp.Dashboard } } else { dashboard = models.NewDashboardFromJson(req.Dashboard) diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go index e72a80e682a..6281afbbf63 100644 --- a/pkg/services/dashboardimport/service/service_test.go +++ b/pkg/services/dashboardimport/service/service_test.go @@ -8,16 +8,16 @@ import ( "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/dashboardimport" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/librarypanels" + "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/stretchr/testify/require" ) func TestImportDashboardService(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, } @@ -52,7 +52,7 @@ func TestImportDashboardService(t *testing.T) { }, } s := &ImportDashboardService{ - pluginDashboardManager: pluginDashboardManager, + pluginDashboardService: pluginDashboardService, dashboardService: dashboardService, libraryPanelService: libraryPanelService, } @@ -108,11 +108,14 @@ func TestImportDashboardService(t *testing.T) { libraryPanelService: libraryPanelService, } - dash, err := loadTestDashboard(context.Background(), "", "dashboard.json") + loadResp, err := loadTestDashboard(context.Background(), &plugindashboards.LoadPluginDashboardRequest{ + PluginID: "", + Reference: "dashboard.json", + }) require.NoError(t, err) req := &dashboardimport.ImportDashboardRequest{ - Dashboard: dash.Data, + Dashboard: loadResp.Dashboard.Data, Path: "plugin_dashboard.json", Inputs: []dashboardimport.ImportDashboardInput{ {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. // nolint:gosec - bytes, err := ioutil.ReadFile(filepath.Join("testdata", path)) + bytes, err := ioutil.ReadFile(filepath.Join("testdata", req.Reference)) if err != nil { return nil, err } @@ -149,17 +152,19 @@ func loadTestDashboard(ctx context.Context, pluginID, path string) (*models.Dash return nil, err } - return models.NewDashboardFromJson(dashboardJSON), nil + return &plugindashboards.LoadPluginDashboardResponse{ + Dashboard: models.NewDashboardFromJson(dashboardJSON), + }, nil } -type pluginDashboardManagerMock struct { - plugins.PluginDashboardManager - loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) +type pluginDashboardServiceMock struct { + plugindashboards.Service + 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 { - return m.loadPluginDashboardFunc(ctx, pluginID, path) + return m.loadPluginDashboardFunc(ctx, req) } return nil, nil diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index 9bdbe935f99..8a243bb97ac 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -6,6 +6,8 @@ import ( "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. type DashboardService interface { 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 } +// 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 // DashboardProvisioningService is a service for operating on provisioned dashboards. type DashboardProvisioningService interface { @@ -29,7 +36,6 @@ type DashboardProvisioningService interface { 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. type Store interface { // ValidateDashboardBeforeSave validates a dashboard before save. @@ -46,4 +52,6 @@ type Store interface { // SaveAlerts saves dashboard alerts. SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error UnprovisionDashboard(ctx context.Context, id int64) error + // GetDashboardsByPluginID retrieves dashboards identified by plugin. + GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error } diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 611935c2ab3..2832a1c32b6 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -635,3 +635,14 @@ func EnsureTagsExist(sess *sqlstore.DBSession, tags []*models.Tag) ([]*models.Ta 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 + }) +} diff --git a/pkg/services/dashboards/database/database_dashboard_test.go b/pkg/services/dashboards/database/database_dashboard_test.go index f02745b4512..c093f9c3d3d 100644 --- a/pkg/services/dashboards/database/database_dashboard_test.go +++ b/pkg/services/dashboards/database/database_dashboard_test.go @@ -421,7 +421,7 @@ func TestDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) { OrgId: 1, } - err := sqlStore.GetDashboardsByPluginId(context.Background(), &query) + err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query) require.NoError(t, err) require.Equal(t, len(query.Result), 2) } diff --git a/pkg/services/dashboards/database_mock.go b/pkg/services/dashboards/database_mock.go index cef866f635e..63ef2177daa 100644 --- a/pkg/services/dashboards/database_mock.go +++ b/pkg/services/dashboards/database_mock.go @@ -5,9 +5,8 @@ package dashboards import ( context "context" - mock "github.com/stretchr/testify/mock" - models "github.com/grafana/grafana/pkg/models" + mock "github.com/stretchr/testify/mock" ) // FakeDashboardStore is an autogenerated mock type for the Store type @@ -29,6 +28,20 @@ func (_m *FakeDashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Co 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 func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) { ret := _m.Called(orgID, title) diff --git a/pkg/services/dashboards/manager/dashboard_service.go b/pkg/services/dashboards/manager/dashboard_service.go index 8ae02b0e728..1fed5acfc68 100644 --- a/pkg/services/dashboards/manager/dashboard_service.go +++ b/pkg/services/dashboards/manager/dashboard_service.go @@ -443,6 +443,10 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo 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 { inFolder := dash.FolderId > 0 if dr.features.IsEnabled(featuremgmt.FlagAccesscontrol) { diff --git a/pkg/services/plugindashboards/plugindashboards.go b/pkg/services/plugindashboards/plugindashboards.go index feaaf48e382..8c2aeea8fd8 100644 --- a/pkg/services/plugindashboards/plugindashboards.go +++ b/pkg/services/plugindashboards/plugindashboards.go @@ -2,166 +2,55 @@ package plugindashboards 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/pluginsettings" ) -func ProvideService(bus bus.Bus, pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager, - dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service) *Service { - s := newService(bus, pluginStore, pluginDashboardManager, dashboardImportService, pluginSettingsService) - s.updateAppDashboards() - return s +// PluginDashboard plugin dashboard model.. +type PluginDashboard struct { + UID string `json:"uid"` + PluginId string `json:"pluginId"` + 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, - dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service) *Service { - s := &Service{ - bus: bus, - pluginStore: pluginStore, - pluginDashboardManager: pluginDashboardManager, - dashboardImportService: dashboardImportService, - pluginSettingsService: pluginSettingsService, - logger: log.New("plugindashboards"), - } - bus.AddEventListener(s.handlePluginStateChanged) - - return s +// ListPluginDashboardsRequest request object for listing plugin dashboards. +type ListPluginDashboardsRequest struct { + OrgID int64 + PluginID string } -type Service struct { - bus bus.Bus - pluginStore plugins.Store - pluginDashboardManager plugins.PluginDashboardManager - dashboardImportService dashboardimport.Service - pluginSettingsService pluginsettings.Service - logger log.Logger +// ListPluginDashboardsResponse response object for listing plugin dashboards. +type ListPluginDashboardsResponse struct { + Items []*PluginDashboard } -func (s *Service) updateAppDashboards() { - s.logger.Debug("Looking for app dashboard updates") - - pluginSettings, err := s.pluginSettingsService.GetPluginSettings(context.Background(), 0) - 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) - } - } - } +// LoadPluginDashboardRequest request object for loading a plugin dashboard. +type LoadPluginDashboardRequest struct { + PluginID string + Reference string } -func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.PluginDTO, orgID int64) { - s.logger.Info("Syncing plugin dashboards to DB", "pluginId", plugin.ID) - - // 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) - } +// LoadPluginDashboardResponse response object for loading a plugin dashboard. +type LoadPluginDashboardResponse struct { + Dashboard *models.Dashboard } -func (s *Service) handlePluginStateChanged(ctx context.Context, event *models.PluginStateChangedEvent) error { - s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled) +// Service interface for listing plugin dashboards. +type Service interface { + // ListPluginDashboards list plugin dashboards identified by org/plugin. + ListPluginDashboards(ctx context.Context, req *ListPluginDashboardsRequest) (*ListPluginDashboardsResponse, error) - if event.Enabled { - p, exists := s.pluginStore.Plugin(ctx, event.PluginId) - 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 + // LoadPluginDashboard loads a plugin dashboard identified by plugin and reference. + LoadPluginDashboard(ctx context.Context, req *LoadPluginDashboardRequest) (*LoadPluginDashboardResponse, error) } diff --git a/pkg/services/plugindashboards/service/dashboard_updater.go b/pkg/services/plugindashboards/service/dashboard_updater.go new file mode 100644 index 00000000000..88409c968fd --- /dev/null +++ b/pkg/services/plugindashboards/service/dashboard_updater.go @@ -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 +} diff --git a/pkg/services/plugindashboards/plugindashboards_test.go b/pkg/services/plugindashboards/service/dashboard_updater_test.go similarity index 62% rename from pkg/services/plugindashboards/plugindashboards_test.go rename to pkg/services/plugindashboards/service/dashboard_updater_test.go index 76fc9eed2e3..efb0c50878b 100644 --- a/pkg/services/plugindashboards/plugindashboards_test.go +++ b/pkg/services/plugindashboards/service/dashboard_updater_test.go @@ -1,4 +1,4 @@ -package plugindashboards +package service import ( "context" @@ -9,19 +9,21 @@ import ( "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/service" "github.com/stretchr/testify/require" ) -func TestService(t *testing.T) { +func TestDashboardUpdater(t *testing.T) { t.Run("updateAppDashboards", func(t *testing.T) { scenario(t, "Without any stored plugin settings shouldn't delete/import any dashboards", scenarioInput{}, func(ctx *scenarioContext) { - ctx.s.updateAppDashboards() + ctx.dashboardUpdater.updateAppDashboards() require.Len(t, ctx.pluginSettingsService.getPluginSettingsArgs, 1) 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) }) @@ -33,17 +35,17 @@ func TestService(t *testing.T) { Enabled: false, }, }, - pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + pluginDashboards: []*plugindashboards.PluginDashboard{ { - PluginId: "test", - Path: "dashboard.json", + PluginId: "test", + Reference: "dashboard.json", }, }, }, func(ctx *scenarioContext) { - ctx.s.updateAppDashboards() + ctx.dashboardUpdater.updateAppDashboards() require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) - require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.dashboardService.deleteDashboardArgs) require.Empty(t, ctx.importDashboardArgs) }) @@ -55,17 +57,17 @@ func TestService(t *testing.T) { Enabled: true, }, }, - pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + pluginDashboards: []*plugindashboards.PluginDashboard{ { - PluginId: "test", - Path: "dashboard.json", + PluginId: "test", + Reference: "dashboard.json", }, }, }, func(ctx *scenarioContext) { - ctx.s.updateAppDashboards() + ctx.dashboardUpdater.updateAppDashboards() require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) - require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.dashboardService.deleteDashboardArgs) require.Empty(t, ctx.importDashboardArgs) }) @@ -87,17 +89,17 @@ func TestService(t *testing.T) { }, }, }, - pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + pluginDashboards: []*plugindashboards.PluginDashboard{ { - PluginId: "test", - Path: "dashboard.json", + PluginId: "test", + Reference: "dashboard.json", }, }, }, func(ctx *scenarioContext) { - ctx.s.updateAppDashboards() + ctx.dashboardUpdater.updateAppDashboards() require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) - require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.dashboardService.deleteDashboardArgs) require.Empty(t, ctx.importDashboardArgs) }) @@ -119,20 +121,20 @@ func TestService(t *testing.T) { }, }, }, - pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + pluginDashboards: []*plugindashboards.PluginDashboard{ { PluginId: "test", - Path: "dashboard.json", + Reference: "dashboard.json", Removed: false, Revision: 1, ImportedRevision: 1, }, }, }, func(ctx *scenarioContext) { - ctx.s.updateAppDashboards() + ctx.dashboardUpdater.updateAppDashboards() require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) - require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.dashboardService.deleteDashboardArgs) require.Empty(t, ctx.importDashboardArgs) }) @@ -156,33 +158,33 @@ func TestService(t *testing.T) { }, }, }, - pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + pluginDashboards: []*plugindashboards.PluginDashboard{ { DashboardId: 3, PluginId: "test", - Path: "removed.json", + Reference: "removed.json", Removed: true, }, { DashboardId: 4, PluginId: "test", - Path: "not-updated.json", + Reference: "not-updated.json", }, { DashboardId: 5, PluginId: "test", - Path: "updated.json", + Reference: "updated.json", Revision: 1, ImportedRevision: 2, }, }, }, func(ctx *scenarioContext) { - ctx.s.updateAppDashboards() + ctx.dashboardUpdater.updateAppDashboards() require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs) - require.Len(t, ctx.deleteDashboardArgs, 1) - require.Equal(t, int64(2), ctx.deleteDashboardArgs[0].OrgId) - require.Equal(t, int64(3), ctx.deleteDashboardArgs[0].Id) + require.Len(t, ctx.dashboardService.deleteDashboardArgs, 1) + require.Equal(t, int64(2), ctx.dashboardService.deleteDashboardArgs[0].orgId) + require.Equal(t, int64(3), ctx.dashboardService.deleteDashboardArgs[0].dashboardId) require.Len(t, ctx.importDashboardArgs, 1) require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId) @@ -204,10 +206,10 @@ func TestService(t *testing.T) { }) require.NoError(t, err) - require.Len(t, ctx.getDashboardsByPluginIdQueryArgs, 1) - require.Equal(t, int64(2), ctx.getDashboardsByPluginIdQueryArgs[0].OrgId) - require.Equal(t, "test", ctx.getDashboardsByPluginIdQueryArgs[0].PluginId) - require.Empty(t, ctx.deleteDashboardArgs) + require.Len(t, ctx.dashboardPluginService.args, 1) + require.Equal(t, int64(2), ctx.dashboardPluginService.args[0].OrgId) + require.Equal(t, "test", ctx.dashboardPluginService.args[0].PluginId) + require.Empty(t, ctx.dashboardService.deleteDashboardArgs) }) }) @@ -227,21 +229,21 @@ func TestService(t *testing.T) { }, }, }, - pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + pluginDashboards: []*plugindashboards.PluginDashboard{ { DashboardId: 3, PluginId: "test", - Path: "dashboard1.json", + Reference: "dashboard1.json", }, { DashboardId: 4, PluginId: "test", - Path: "dashboard2.json", + Reference: "dashboard2.json", }, { DashboardId: 5, PluginId: "test", - Path: "dashboard3.json", + Reference: "dashboard3.json", }, }, }, func(ctx *scenarioContext) { @@ -252,10 +254,10 @@ func TestService(t *testing.T) { }) require.NoError(t, err) - require.Len(t, ctx.getDashboardsByPluginIdQueryArgs, 1) - require.Equal(t, int64(2), ctx.getDashboardsByPluginIdQueryArgs[0].OrgId) - require.Equal(t, "test", ctx.getDashboardsByPluginIdQueryArgs[0].PluginId) - require.Len(t, ctx.deleteDashboardArgs, 3) + require.Len(t, ctx.dashboardPluginService.args, 1) + require.Equal(t, int64(2), ctx.dashboardPluginService.args[0].OrgId) + require.Equal(t, "test", ctx.dashboardPluginService.args[0].PluginId) + require.Len(t, ctx.dashboardService.deleteDashboardArgs, 3) }) 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, PluginId: "test", - Path: "dashboard1.json", + Reference: "dashboard1.json", Revision: 1, ImportedRevision: 0, }, { DashboardId: 4, PluginId: "test", - Path: "dashboard2.json", + Reference: "dashboard2.json", Revision: 1, ImportedRevision: 0, }, { DashboardId: 5, PluginId: "test", - Path: "dashboard3.json", + Reference: "dashboard3.json", Revision: 1, ImportedRevision: 0, }, @@ -309,7 +311,7 @@ func TestService(t *testing.T) { }) require.NoError(t, err) - require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.dashboardService.deleteDashboardArgs) require.Len(t, ctx.importDashboardArgs, 3) 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 } -type pluginDashboardManagerMock struct { - plugins.PluginDashboardManager - getPluginDashboardsFunc func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) - loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) +type pluginDashboardServiceMock struct { + listPluginDashboardsFunc func(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) + loadPluginDashboardfunc func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) } -func (m *pluginDashboardManagerMock) GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { - if m.getPluginDashboardsFunc != nil { - return m.getPluginDashboardsFunc(ctx, orgID, pluginID) +func (m *pluginDashboardServiceMock) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) { + if m.listPluginDashboardsFunc != nil { + 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) { - if m.loadPluginDashboardFunc != nil { - return m.loadPluginDashboardFunc(ctx, pluginID, path) +func (m *pluginDashboardServiceMock) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) { + if m.loadPluginDashboardfunc != nil { + return m.loadPluginDashboardfunc(ctx, req) } return nil, nil @@ -418,38 +421,55 @@ func (s *pluginsSettingsServiceMock) UpdatePluginSetting(_ context.Context, _ *m 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 { storedPluginSettings []*models.PluginSettingInfoDTO installedPlugins []plugins.PluginDTO - pluginDashboards []*plugins.PluginDashboardInfoDTO + pluginDashboards []*plugindashboards.PluginDashboard } type scenarioContext struct { - t *testing.T - bus bus.Bus - pluginSettingsService *pluginsSettingsServiceMock - pluginStore plugins.Store - pluginDashboardManager plugins.PluginDashboardManager - importDashboardService dashboardimport.Service - importDashboardArgs []*dashboardimport.ImportDashboardRequest - deleteDashboardArgs []*models.DeleteDashboardCommand - getPluginSettingsByIdArgs []*models.GetPluginSettingByIdQuery - updatePluginSettingVersionArgs []*models.UpdatePluginSettingVersionCmd - getDashboardsByPluginIdQueryArgs []*models.GetDashboardsByPluginIdQuery - s *Service + t *testing.T + bus bus.Bus + pluginSettingsService *pluginsSettingsServiceMock + pluginStore plugins.Store + pluginDashboardService plugindashboards.Service + importDashboardService dashboardimport.Service + dashboardPluginService *dashboardPluginServiceMock + dashboardService *dashboardServiceMock + importDashboardArgs []*dashboardimport.ImportDashboardRequest + getPluginSettingsByIdArgs []*models.GetPluginSettingByIdQuery + updatePluginSettingVersionArgs []*models.UpdatePluginSettingVersionCmd + dashboardUpdater *DashboardUpdater } func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenarioContext)) { t.Helper() sCtx := &scenarioContext{ - t: t, - bus: bus.New(), - importDashboardArgs: []*dashboardimport.ImportDashboardRequest{}, - deleteDashboardArgs: []*models.DeleteDashboardCommand{}, - getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{}, - updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{}, - getDashboardsByPluginIdQueryArgs: []*models.GetDashboardsByPluginIdQuery{}, + t: t, + bus: bus.New(), + importDashboardArgs: []*dashboardimport.ImportDashboardRequest{}, + getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{}, + updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{}, } 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, } - getPluginDashboards := func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { - dashboards := []*plugins.PluginDashboardInfoDTO{} + pluginDashboards := map[string][]*models.Dashboard{} + 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 { - if d.PluginId == pluginID { + if d.PluginId == req.PluginID { 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 { - if d.PluginId == pluginID && path == d.Path { - return &models.Dashboard{}, nil + if d.PluginId == req.PluginID && req.Reference == d.Reference { + return &plugindashboards.LoadPluginDashboardResponse{ + Dashboard: &models.Dashboard{}, + }, nil } } return nil, fmt.Errorf("no match for loading plugin dashboard") } - sCtx.pluginDashboardManager = &pluginDashboardManagerMock{ - getPluginDashboardsFunc: getPluginDashboards, - loadPluginDashboardFunc: loadPluginDashboard, + sCtx.pluginDashboardService = &pluginDashboardServiceMock{ + listPluginDashboardsFunc: listPluginDashboards, + loadPluginDashboardfunc: loadPluginDashboard, } 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, } - sCtx.bus.AddHandler(func(ctx context.Context, cmd *models.DeleteDashboardCommand) error { - sCtx.deleteDashboardArgs = append(sCtx.deleteDashboardArgs, cmd) - - return nil - }) - - sCtx.bus.AddHandler(func(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error { - sCtx.getDashboardsByPluginIdQueryArgs = append(sCtx.getDashboardsByPluginIdQueryArgs, query) - 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) + sCtx.dashboardUpdater = newDashboardUpdater( + sCtx.bus, + sCtx.pluginStore, + sCtx.pluginDashboardService, + sCtx.importDashboardService, + sCtx.pluginSettingsService, + sCtx.dashboardPluginService, + sCtx.dashboardService, + ) t.Cleanup(bus.ClearBusHandlers) diff --git a/pkg/services/plugindashboards/service/service.go b/pkg/services/plugindashboards/service/service.go new file mode 100644 index 00000000000..620910887a9 --- /dev/null +++ b/pkg/services/plugindashboards/service/service.go @@ -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{} diff --git a/pkg/services/plugindashboards/service/service_test.go b/pkg/services/plugindashboards/service/service_test.go new file mode 100644 index 00000000000..e0f5404feb7 --- /dev/null +++ b/pkg/services/plugindashboards/service/service_test.go @@ -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 +} diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index ddbf3b94541..85c202d46f8 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -39,7 +39,6 @@ func (ss *SQLStore) addDashboardQueryAndCommandHandlers() { bus.AddHandler("sql", ss.GetDashboards) bus.AddHandler("sql", ss.HasEditPermissionInFolders) bus.AddHandler("sql", ss.GetDashboardPermissionsForUser) - bus.AddHandler("sql", ss.GetDashboardsByPluginId) bus.AddHandler("sql", ss.GetDashboardSlugById) 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 { Slug string } diff --git a/pkg/services/sqlstore/mockstore/mockstore.go b/pkg/services/sqlstore/mockstore/mockstore.go index 256798905e4..49506bfb432 100644 --- a/pkg/services/sqlstore/mockstore/mockstore.go +++ b/pkg/services/sqlstore/mockstore/mockstore.go @@ -660,10 +660,6 @@ func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query 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 { return m.ExpectedError } diff --git a/pkg/services/sqlstore/store.go b/pkg/services/sqlstore/store.go index 9e16be97c1c..b144bcf017d 100644 --- a/pkg/services/sqlstore/store.go +++ b/pkg/services/sqlstore/store.go @@ -150,6 +150,5 @@ type Store interface { SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) 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 } diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 7570afe0c5a..2e41d0d974f 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -14,8 +14,8 @@ import ( "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/dashboardimport" + "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -61,7 +61,7 @@ func TestDashboardQuota(t *testing.T) { }) b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - dashboardDTO := &plugins.PluginDashboardInfoDTO{} + dashboardDTO := &plugindashboards.PluginDashboard{} err = json.Unmarshal(b, dashboardDTO) require.NoError(t, err) require.EqualValues(t, 1, dashboardDTO.DashboardId) diff --git a/pkg/web/webtest/webtest.go b/pkg/web/webtest/webtest.go index d7c480c9b28..7ac09e8e78e 100644 --- a/pkg/web/webtest/webtest.go +++ b/pkg/web/webtest/webtest.go @@ -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. func (s *Server) NewRequest(method string, target string, body io.Reader) *http.Request { s.t.Helper() @@ -65,11 +75,19 @@ func (s *Server) NewRequest(method string, target string, body io.Reader) *http. 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) { 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 { 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 { return RequestWithWebContext(req, &models.ReqContext{ - SignedInUser: &models.SignedInUser{}, + SignedInUser: user, IsSignedIn: true, }) } diff --git a/pkg/web/webtest/webtest_test.go b/pkg/web/webtest/webtest_test.go index 4a5bee8a71b..0432b19e2d0 100644 --- a/pkg/web/webtest/webtest_test.go +++ b/pkg/web/webtest/webtest_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io/ioutil" "net/http" + "strings" "testing" "github.com/grafana/grafana/pkg/api/response" @@ -12,6 +13,69 @@ import ( "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) { routeRegister := routing.NewRouteRegister() routeRegister.Get("/test", routing.Wrap(func(c *models.ReqContext) response.Response {