diff --git a/pkg/api/api.go b/pkg/api/api.go index eda73a91c0e..a16ce442f48 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -342,7 +342,6 @@ func (hs *HTTPServer) registerRoutes() { dashboardRoute.Post("/db", routing.Wrap(hs.PostDashboard)) dashboardRoute.Get("/home", routing.Wrap(hs.GetHomeDashboard)) dashboardRoute.Get("/tags", GetDashboardTags) - dashboardRoute.Post("/import", routing.Wrap(hs.ImportDashboard)) dashboardRoute.Group("/id/:dashboardId", func(dashIdRoute routing.RouteRegister) { dashIdRoute.Get("/versions", routing.Wrap(GetDashboardVersions)) diff --git a/pkg/api/apierrors/dashboard.go b/pkg/api/apierrors/dashboard.go new file mode 100644 index 00000000000..a4003646143 --- /dev/null +++ b/pkg/api/apierrors/dashboard.go @@ -0,0 +1,49 @@ +package apierrors + +import ( + "context" + "errors" + "fmt" + "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/alerting" + "github.com/grafana/grafana/pkg/util" +) + +// ToDashboardErrorResponse returns a different response status according to the dashboard error type +func ToDashboardErrorResponse(ctx context.Context, pluginStore plugins.Store, err error) response.Response { + var dashboardErr models.DashboardErr + if ok := errors.As(err, &dashboardErr); ok { + if body := dashboardErr.Body(); body != nil { + return response.JSON(dashboardErr.StatusCode, body) + } + if dashboardErr.StatusCode != http.StatusBadRequest { + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), err) + } + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), nil) + } + + if errors.Is(err, models.ErrFolderNotFound) { + return response.Error(http.StatusBadRequest, err.Error(), nil) + } + + var validationErr alerting.ValidationError + if ok := errors.As(err, &validationErr); ok { + return response.Error(http.StatusUnprocessableEntity, validationErr.Error(), err) + } + + var pluginErr models.UpdatePluginDashboardError + if ok := errors.As(err, &pluginErr); ok { + message := fmt.Sprintf("The dashboard belongs to plugin %s.", pluginErr.PluginId) + // look up plugin name + if plugin, exists := pluginStore.Plugin(ctx, pluginErr.PluginId); exists { + message = fmt.Sprintf("The dashboard belongs to plugin %s.", plugin.Name) + } + return response.JSON(http.StatusPreconditionFailed, util.DynMap{"status": "plugin-dashboard", "message": message}) + } + + return response.Error(http.StatusInternalServerError, "Failed to save dashboard", err) +} diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 66ad3f16c09..32763f62a6c 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" + "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/bus" @@ -381,7 +382,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa } if err != nil { - return hs.dashboardSaveErrorToApiResponse(ctx, err) + return apierrors.ToDashboardErrorResponse(ctx, hs.pluginStore, err) } if hs.Cfg.EditorsCanAdmin && newDashboard { @@ -409,40 +410,6 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa }) } -func (hs *HTTPServer) dashboardSaveErrorToApiResponse(ctx context.Context, err error) response.Response { - var dashboardErr models.DashboardErr - if ok := errors.As(err, &dashboardErr); ok { - if body := dashboardErr.Body(); body != nil { - return response.JSON(dashboardErr.StatusCode, body) - } - if dashboardErr.StatusCode != 400 { - return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), err) - } - return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), nil) - } - - if errors.Is(err, models.ErrFolderNotFound) { - return response.Error(400, err.Error(), nil) - } - - var validationErr alerting.ValidationError - if ok := errors.As(err, &validationErr); ok { - return response.Error(422, validationErr.Error(), err) - } - - var pluginErr models.UpdatePluginDashboardError - if ok := errors.As(err, &pluginErr); ok { - message := fmt.Sprintf("The dashboard belongs to plugin %s.", pluginErr.PluginId) - // look up plugin name - if plugin, exists := hs.pluginStore.Plugin(ctx, pluginErr.PluginId); exists { - message = fmt.Sprintf("The dashboard belongs to plugin %s.", plugin.Name) - } - return response.JSON(412, util.DynMap{"status": "plugin-dashboard", "message": message}) - } - - return response.Error(500, "Failed to save dashboard", err) -} - // GetHomeDashboard returns the home dashboard. func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { prefsQuery := models.GetPreferencesWithDefaultsQuery{User: c.SignedInUser} diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index dcef9df59aa..aea91af657d 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -1,7 +1,6 @@ package dtos import ( - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/plugins" ) @@ -59,16 +58,6 @@ func (slice PluginList) Swap(i, j int) { slice[i], slice[j] = slice[j], slice[i] } -type ImportDashboardCommand struct { - PluginId string `json:"pluginId"` - Path string `json:"path"` - Overwrite bool `json:"overwrite"` - Dashboard *simplejson.Json `json:"dashboard"` - Inputs []plugins.ImportDashboardInput `json:"inputs"` - FolderId int64 `json:"folderId"` - FolderUid string `json:"folderUid"` -} - type InstallPluginCommand struct { Version string `json:"version"` } diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index ef2bc8827f4..d4d208fde8c 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -217,51 +217,6 @@ func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response return resp } -func (hs *HTTPServer) ImportDashboard(c *models.ReqContext) response.Response { - apiCmd := dtos.ImportDashboardCommand{} - if err := web.Bind(c.Req, &apiCmd); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - var err error - if apiCmd.PluginId == "" && apiCmd.Dashboard == nil { - return response.Error(422, "Dashboard must be set", nil) - } - - limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") - if err != nil { - return response.Error(500, "failed to get quota", err) - } - if limitReached { - return response.Error(403, "Quota reached", nil) - } - - trimDefaults := c.QueryBoolWithDefault("trimdefaults", true) - if trimDefaults && !hs.LoadSchemaService.IsDisabled() { - apiCmd.Dashboard, err = hs.LoadSchemaService.DashboardApplyDefaults(apiCmd.Dashboard) - if err != nil { - return response.Error(500, "Error while applying default value to the dashboard json", err) - } - } - - dashInfo, dash, err := hs.pluginDashboardManager.ImportDashboard(c.Req.Context(), apiCmd.PluginId, apiCmd.Path, c.OrgId, apiCmd.FolderId, - apiCmd.Dashboard, apiCmd.Overwrite, apiCmd.Inputs, c.SignedInUser) - if err != nil { - return hs.dashboardSaveErrorToApiResponse(c.Req.Context(), err) - } - - err = hs.LibraryPanelService.ImportLibraryPanelsForDashboard(c.Req.Context(), c.SignedInUser, dash, apiCmd.FolderId) - if err != nil { - return response.Error(500, "Error while importing library panels", err) - } - - err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(c.Req.Context(), c.SignedInUser, dash) - if err != nil { - return response.Error(500, "Error while connecting library panels", err) - } - - return response.JSON(200, dashInfo) -} - // CollectPluginMetrics collect metrics from a plugin. // // /api/plugins/:pluginId/metrics diff --git a/pkg/plugins/dashboards.go b/pkg/plugins/dashboards.go deleted file mode 100644 index f2c6cd6063c..00000000000 --- a/pkg/plugins/dashboards.go +++ /dev/null @@ -1,18 +0,0 @@ -package plugins - -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"` -} diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index d8f720988d2..3e106fb34a8 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/backendplugin" ) @@ -76,20 +75,26 @@ 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"` +} + 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) - // ImportDashboard imports a dashboard. - ImportDashboard(ctx context.Context, pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json, - overwrite bool, inputs []ImportDashboardInput, user *models.SignedInUser) (PluginDashboardInfoDTO, - *models.Dashboard, error) -} - -type ImportDashboardInput struct { - Type string `json:"type"` - PluginId string `json:"pluginId"` - Name string `json:"name"` - Value string `json:"value"` } diff --git a/pkg/plugins/manager/dashboard_import_test.go b/pkg/plugins/manager/dashboard_import_test.go deleted file mode 100644 index b4594930739..00000000000 --- a/pkg/plugins/manager/dashboard_import_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package manager - -import ( - "context" - "io/ioutil" - "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/plugins/backendplugin/provider" - "github.com/grafana/grafana/pkg/plugins/manager/loader" - "github.com/grafana/grafana/pkg/plugins/manager/signature" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/require" -) - -func TestDashboardImport(t *testing.T) { - pluginScenario(t, "When importing a plugin dashboard", func(t *testing.T, pm *PluginManager) { - origNewDashboardService := dashboards.NewService - t.Cleanup(func() { - dashboards.NewService = origNewDashboardService - }) - mock := &dashboards.FakeDashboardService{} - dashboards.MockDashboardService(mock) - - info, dash, err := pm.ImportDashboard(context.Background(), "test-app", "dashboards/connections.json", 1, 0, nil, false, - []plugins.ImportDashboardInput{ - {Name: "*", Type: "datasource", Value: "graphite"}, - }, &models.SignedInUser{UserId: 1, OrgRole: models.ROLE_ADMIN}) - require.NoError(t, err) - require.NotNil(t, info) - require.NotNil(t, dash) - - resultStr, err := mock.SavedDashboards[0].Dashboard.Data.EncodePretty() - require.NoError(t, err) - expectedBytes, err := ioutil.ReadFile("testdata/test-app/dashboards/connections_result.json") - require.NoError(t, err) - expectedJson, err := simplejson.NewJson(expectedBytes) - require.NoError(t, err) - expectedStr, err := expectedJson.EncodePretty() - require.NoError(t, err) - - require.Equal(t, expectedStr, resultStr) - - panel := mock.SavedDashboards[0].Dashboard.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0) - require.Equal(t, "graphite", panel.Get("datasource").MustString()) - }) - - t.Run("When evaling dashboard template", func(t *testing.T) { - template, err := simplejson.NewJson([]byte(`{ - "__inputs": [ - { - "name": "DS_NAME", - "type": "datasource" - } - ], - "test": { - "prop": "${DS_NAME}_${DS_NAME}" - } - }`)) - require.NoError(t, err) - - evaluator := &DashTemplateEvaluator{ - template: template, - inputs: []plugins.ImportDashboardInput{ - {Name: "*", Type: "datasource", Value: "my-server"}, - }, - } - - res, err := evaluator.Eval() - require.NoError(t, err) - - require.Equal(t, "my-server_my-server", res.GetPath("test", "prop").MustString()) - - inputs := res.Get("__inputs") - require.Nil(t, inputs.Interface()) - }) -} - -func pluginScenario(t *testing.T, desc string, fn func(*testing.T, *PluginManager)) { - t.Helper() - - t.Run("Given a plugin", func(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{}), &sqlstore.SQLStore{}) - require.NoError(t, err) - - t.Run(desc, func(t *testing.T) { - fn(t, pm) - }) - }) -} diff --git a/pkg/plugins/manager/dashboards.go b/pkg/plugins/manager/dashboards.go index c1a6eee1d8c..a90a8125ac6 100644 --- a/pkg/plugins/manager/dashboards.go +++ b/pkg/plugins/manager/dashboards.go @@ -11,7 +11,6 @@ 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/dashboards" "github.com/grafana/grafana/pkg/util" ) @@ -134,63 +133,3 @@ func (m *PluginManager) LoadPluginDashboard(ctx context.Context, pluginID, path return models.NewDashboardFromJson(data), nil } - -func (m *PluginManager) ImportDashboard(ctx context.Context, pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json, - overwrite bool, inputs []plugins.ImportDashboardInput, user *models.SignedInUser) (plugins.PluginDashboardInfoDTO, - *models.Dashboard, error) { - var dashboard *models.Dashboard - if pluginID != "" { - var err error - if dashboard, err = m.LoadPluginDashboard(ctx, pluginID, path); err != nil { - return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err - } - } else { - dashboard = models.NewDashboardFromJson(dashboardModel) - } - - evaluator := &DashTemplateEvaluator{ - template: dashboard.Data, - inputs: inputs, - } - - generatedDash, err := evaluator.Eval() - if err != nil { - return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err - } - - saveCmd := models.SaveDashboardCommand{ - Dashboard: generatedDash, - OrgId: orgID, - UserId: user.UserId, - Overwrite: overwrite, - PluginId: pluginID, - FolderId: folderID, - } - - dto := &dashboards.SaveDashboardDTO{ - OrgId: orgID, - Dashboard: saveCmd.GetDashboardModel(), - Overwrite: saveCmd.Overwrite, - User: user, - } - - savedDash, err := dashboards.NewService(m.sqlStore).ImportDashboard(ctx, dto) - if err != nil { - return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err - } - - return plugins.PluginDashboardInfoDTO{ - UID: savedDash.Uid, - PluginId: pluginID, - Title: savedDash.Title, - Path: path, - Revision: savedDash.Data.Get("revision").MustInt64(1), - FolderId: savedDash.FolderId, - ImportedUri: "db/" + savedDash.Slug, - ImportedUrl: savedDash.GetUrl(), - ImportedRevision: dashboard.Data.Get("revision").MustInt64(1), - Imported: true, - DashboardId: savedDash.Id, - Slug: savedDash.Slug, - }, savedDash, nil -} diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index b25d0292941..447e714632d 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -8,7 +8,6 @@ import ( uss "github.com/grafana/grafana/pkg/infra/usagestats/service" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins/manager" - "github.com/grafana/grafana/pkg/plugins/plugindashboards" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/cleanup" @@ -17,6 +16,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" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/rendering" diff --git a/pkg/server/wire.go b/pkg/server/wire.go index c998cc16edd..c54cdc58089 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -27,12 +27,13 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/plugincontext" - "github.com/grafana/grafana/pkg/plugins/plugindashboards" "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/auth/jwt" "github.com/grafana/grafana/pkg/services/cleanup" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/dashboardimport" + dashboardimportservice "github.com/grafana/grafana/pkg/services/dashboardimport/service" "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" @@ -49,6 +50,7 @@ import ( ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/oauthtoken" + "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/pluginsettings" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/quota" @@ -145,7 +147,6 @@ var wireBasicSet = wire.NewSet( contexthandler.ProvideService, jwt.ProvideService, wire.Bind(new(models.JWTService), new(*jwt.AuthService)), - plugindashboards.ProvideService, schemaloader.ProvideService, ngalert.ProvideService, librarypanels.ProvideService, @@ -188,6 +189,9 @@ var wireBasicSet = wire.NewSet( featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, resourceservices.ProvideResourceServices, + dashboardimportservice.ProvideService, + wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)), + plugindashboards.ProvideService, ) var wireSet = wire.NewSet( diff --git a/pkg/services/dashboardimport/api/api.go b/pkg/services/dashboardimport/api/api.go new file mode 100644 index 00000000000..9a9249bae33 --- /dev/null +++ b/pkg/services/dashboardimport/api/api.go @@ -0,0 +1,89 @@ +package api + +import ( + "net/http" + + "github.com/grafana/grafana/pkg/api/apierrors" + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/middleware" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/web" +) + +type ImportDashboardAPI struct { + dashboardImportService dashboardimport.Service + quotaService QuotaService + schemaLoaderService SchemaLoaderService + pluginStore plugins.Store +} + +func New(dashboardImportService dashboardimport.Service, quotaService QuotaService, + schemaLoaderService SchemaLoaderService, pluginStore plugins.Store) *ImportDashboardAPI { + return &ImportDashboardAPI{ + dashboardImportService: dashboardImportService, + quotaService: quotaService, + schemaLoaderService: schemaLoaderService, + pluginStore: pluginStore, + } +} + +func (api *ImportDashboardAPI) RegisterAPIEndpoints(routeRegister routing.RouteRegister) { + routeRegister.Group("/api/dashboards", func(route routing.RouteRegister) { + route.Post("/import", routing.Wrap(api.ImportDashboard)) + }, middleware.ReqSignedIn) +} + +func (api *ImportDashboardAPI) ImportDashboard(c *models.ReqContext) response.Response { + req := dashboardimport.ImportDashboardRequest{} + if err := web.Bind(c.Req, &req); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + if req.PluginId == "" && req.Dashboard == nil { + return response.Error(http.StatusUnprocessableEntity, "Dashboard must be set", nil) + } + + limitReached, err := api.quotaService.QuotaReached(c, "dashboard") + if err != nil { + return response.Error(500, "failed to get quota", err) + } + + if limitReached { + return response.Error(403, "Quota reached", nil) + } + + trimDefaults := c.QueryBoolWithDefault("trimdefaults", true) + if trimDefaults && !api.schemaLoaderService.IsDisabled() { + req.Dashboard, err = api.schemaLoaderService.DashboardApplyDefaults(req.Dashboard) + if err != nil { + return response.Error(http.StatusInternalServerError, "Error while applying default value to the dashboard json", err) + } + } + + req.User = c.SignedInUser + resp, err := api.dashboardImportService.ImportDashboard(c.Req.Context(), &req) + if err != nil { + return apierrors.ToDashboardErrorResponse(c.Req.Context(), api.pluginStore, err) + } + + return response.JSON(http.StatusOK, resp) +} + +type QuotaService interface { + QuotaReached(c *models.ReqContext, target string) (bool, error) +} + +type quotaServiceFunc func(c *models.ReqContext, target string) (bool, error) + +func (fn quotaServiceFunc) QuotaReached(c *models.ReqContext, target string) (bool, error) { + return fn(c, target) +} + +type SchemaLoaderService interface { + IsDisabled() bool + DashboardApplyDefaults(input *simplejson.Json) (*simplejson.Json, error) +} diff --git a/pkg/services/dashboardimport/api/api_test.go b/pkg/services/dashboardimport/api/api_test.go new file mode 100644 index 00000000000..29b24197933 --- /dev/null +++ b/pkg/services/dashboardimport/api/api_test.go @@ -0,0 +1,209 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/web/webtest" + "github.com/stretchr/testify/require" +) + +func TestImportDashboardAPI(t *testing.T) { + t.Run("Quota not reached, schema loader service disabled", func(t *testing.T) { + importDashboardServiceCalled := false + service := &serviceMock{ + importDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { + importDashboardServiceCalled = true + return nil, nil + }, + } + + schemaLoaderServiceCalled := false + schemaLoaderService := &schemaLoaderServiceMock{ + dashboardApplyDefaultsFunc: func(input *simplejson.Json) (*simplejson.Json, error) { + schemaLoaderServiceCalled = true + return input, nil + }, + } + + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), schemaLoaderService, nil) + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + t.Run("Not signed in should return 404", func(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)) + 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, empty plugin id and dashboard model empty should return error", func(t *testing.T) { + cmd := &dashboardimport.ImportDashboardRequest{ + PluginId: "", + Dashboard: nil, + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{ + UserId: 1, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) + }) + + t.Run("Signed in, dashboard model set should call import dashboard service", func(t *testing.T) { + cmd := &dashboardimport.ImportDashboardRequest{ + Dashboard: simplejson.New(), + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{ + UserId: 1, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, importDashboardServiceCalled) + }) + + t.Run("Signed in, dashboard model set, trimdefaults enabled should not call schema loader service", func(t *testing.T) { + cmd := &dashboardimport.ImportDashboardRequest{ + Dashboard: simplejson.New(), + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{ + UserId: 1, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.False(t, schemaLoaderServiceCalled) + require.True(t, importDashboardServiceCalled) + }) + }) + + t.Run("Quota not reached, schema loader service enabled", func(t *testing.T) { + importDashboardServiceCalled := false + service := &serviceMock{ + importDashboardFunc: func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { + importDashboardServiceCalled = true + return nil, nil + }, + } + + schemaLoaderServiceCalled := false + schemaLoaderService := &schemaLoaderServiceMock{ + enabled: true, + dashboardApplyDefaultsFunc: func(input *simplejson.Json) (*simplejson.Json, error) { + schemaLoaderServiceCalled = true + return input, nil + }, + } + + importDashboardAPI := New(service, quotaServiceFunc(quotaNotReached), schemaLoaderService, nil) + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + t.Run("Signed in, dashboard model set, trimdefaults enabled should call schema loader service", func(t *testing.T) { + cmd := &dashboardimport.ImportDashboardRequest{ + Dashboard: simplejson.New(), + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{ + UserId: 1, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.True(t, schemaLoaderServiceCalled) + require.True(t, importDashboardServiceCalled) + }) + }) + + t.Run("Quota reached", func(t *testing.T) { + service := &serviceMock{} + schemaLoaderService := &schemaLoaderServiceMock{} + importDashboardAPI := New(service, quotaServiceFunc(quotaReached), schemaLoaderService, nil) + + routeRegister := routing.NewRouteRegister() + importDashboardAPI.RegisterAPIEndpoints(routeRegister) + s := webtest.NewServer(t, routeRegister) + + t.Run("Signed in, dashboard model set, should return 403 forbidden/quota reached", func(t *testing.T) { + cmd := &dashboardimport.ImportDashboardRequest{ + Dashboard: simplejson.New(), + } + jsonBytes, err := json.Marshal(cmd) + require.NoError(t, err) + req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes)) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{ + UserId: 1, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + }) +} + +type serviceMock struct { + importDashboardFunc func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) +} + +func (s *serviceMock) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { + if s.importDashboardFunc != nil { + return s.importDashboardFunc(ctx, req) + } + + return nil, nil +} + +type schemaLoaderServiceMock struct { + enabled bool + dashboardApplyDefaultsFunc func(input *simplejson.Json) (*simplejson.Json, error) +} + +func (s *schemaLoaderServiceMock) IsDisabled() bool { + return !s.enabled +} + +func (s *schemaLoaderServiceMock) DashboardApplyDefaults(input *simplejson.Json) (*simplejson.Json, error) { + if s.dashboardApplyDefaultsFunc != nil { + return s.dashboardApplyDefaultsFunc(input) + } + + return input, nil +} + +func quotaReached(c *models.ReqContext, target string) (bool, error) { + return true, nil +} + +func quotaNotReached(c *models.ReqContext, target string) (bool, error) { + return false, nil +} diff --git a/pkg/services/dashboardimport/dashboardimport.go b/pkg/services/dashboardimport/dashboardimport.go new file mode 100644 index 00000000000..86d80064a15 --- /dev/null +++ b/pkg/services/dashboardimport/dashboardimport.go @@ -0,0 +1,52 @@ +package dashboardimport + +import ( + "context" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" +) + +// ImportDashboardInput definition of input parameters when importing a dashboard. +type ImportDashboardInput struct { + Type string `json:"type"` + PluginId string `json:"pluginId"` + Name string `json:"name"` + Value string `json:"value"` +} + +// ImportDashboardRequest request object for importing a dashboard. +type ImportDashboardRequest struct { + PluginId string `json:"pluginId"` + Path string `json:"path"` + Overwrite bool `json:"overwrite"` + Dashboard *simplejson.Json `json:"dashboard"` + Inputs []ImportDashboardInput `json:"inputs"` + FolderId int64 `json:"folderId"` + FolderUid string `json:"folderUid"` + + User *models.SignedInUser `json:"-"` +} + +// ImportDashboardResponse response object returned when importing a dashboard. +type ImportDashboardResponse 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"` +} + +// Service service interface for importing dashboards. +type Service interface { + ImportDashboard(ctx context.Context, req *ImportDashboardRequest) (*ImportDashboardResponse, error) +} diff --git a/pkg/services/dashboardimport/service/service.go b/pkg/services/dashboardimport/service/service.go new file mode 100644 index 00000000000..1fcfeb3d836 --- /dev/null +++ b/pkg/services/dashboardimport/service/service.go @@ -0,0 +1,103 @@ +package service + +import ( + "context" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/grafana/grafana/pkg/services/dashboardimport/api" + "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/quota" + "github.com/grafana/grafana/pkg/services/schemaloader" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func ProvideService(sqlStore *sqlstore.SQLStore, routeRegister routing.RouteRegister, + quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService, + pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, + libraryPanelService librarypanels.Service) *ImportDashboardService { + s := &ImportDashboardService{ + pluginDashboardManager: pluginDashboardManager, + dashboardService: dashboards.NewService(sqlStore), + libraryPanelService: libraryPanelService, + } + + dashboardImportAPI := api.New(s, quotaService, schemaLoaderService, pluginStore) + dashboardImportAPI.RegisterAPIEndpoints(routeRegister) + + return s +} + +type ImportDashboardService struct { + pluginDashboardManager plugins.PluginDashboardManager + dashboardService dashboards.DashboardService + libraryPanelService librarypanels.Service +} + +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 { + return nil, err + } + } else { + dashboard = models.NewDashboardFromJson(req.Dashboard) + } + + evaluator := utils.NewDashTemplateEvaluator(dashboard.Data, req.Inputs) + generatedDash, err := evaluator.Eval() + if err != nil { + return nil, err + } + + saveCmd := models.SaveDashboardCommand{ + Dashboard: generatedDash, + OrgId: req.User.OrgId, + UserId: req.User.UserId, + Overwrite: req.Overwrite, + PluginId: req.PluginId, + FolderId: req.FolderId, + } + + dto := &dashboards.SaveDashboardDTO{ + OrgId: saveCmd.OrgId, + Dashboard: saveCmd.GetDashboardModel(), + Overwrite: saveCmd.Overwrite, + User: req.User, + } + + savedDash, err := s.dashboardService.ImportDashboard(ctx, dto) + if err != nil { + return nil, err + } + + err = s.libraryPanelService.ImportLibraryPanelsForDashboard(ctx, req.User, savedDash, req.FolderId) + if err != nil { + return nil, err + } + + err = s.libraryPanelService.ConnectLibraryPanelsForDashboard(ctx, req.User, dashboard) + if err != nil { + return nil, err + } + + return &dashboardimport.ImportDashboardResponse{ + UID: savedDash.Uid, + PluginId: req.PluginId, + Title: savedDash.Title, + Path: req.Path, + Revision: savedDash.Data.Get("revision").MustInt64(1), + FolderId: savedDash.FolderId, + ImportedUri: "db/" + savedDash.Slug, + ImportedUrl: savedDash.GetUrl(), + ImportedRevision: dashboard.Data.Get("revision").MustInt64(1), + Imported: true, + DashboardId: savedDash.Id, + Slug: savedDash.Slug, + }, nil +} diff --git a/pkg/services/dashboardimport/service/service_test.go b/pkg/services/dashboardimport/service/service_test.go new file mode 100644 index 00000000000..e72a80e682a --- /dev/null +++ b/pkg/services/dashboardimport/service/service_test.go @@ -0,0 +1,201 @@ +package service + +import ( + "context" + "io/ioutil" + "path/filepath" + "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/dashboardimport" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/librarypanels" + "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{ + loadPluginDashboardFunc: loadTestDashboard, + } + + var importDashboardArg *dashboards.SaveDashboardDTO + dashboardService := &dashboardServiceMock{ + importDashboardFunc: func(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { + importDashboardArg = dto + return &models.Dashboard{ + Id: 4, + Uid: dto.Dashboard.Uid, + Slug: dto.Dashboard.Slug, + OrgId: 3, + Version: dto.Dashboard.Version, + PluginId: "prometheus", + FolderId: dto.Dashboard.FolderId, + Title: dto.Dashboard.Title, + Data: dto.Dashboard.Data, + }, nil + }, + } + + importLibraryPanelsForDashboard := false + connectLibraryPanelsForDashboardCalled := false + libraryPanelService := &libraryPanelServiceMock{ + importLibraryPanelsForDashboardFunc: func(ctx context.Context, signedInUser *models.SignedInUser, dash *models.Dashboard, folderID int64) error { + importLibraryPanelsForDashboard = true + return nil + }, + connectLibraryPanelsForDashboardFunc: func(ctx context.Context, signedInUser *models.SignedInUser, dash *models.Dashboard) error { + connectLibraryPanelsForDashboardCalled = true + return nil + }, + } + s := &ImportDashboardService{ + pluginDashboardManager: pluginDashboardManager, + dashboardService: dashboardService, + libraryPanelService: libraryPanelService, + } + + req := &dashboardimport.ImportDashboardRequest{ + PluginId: "prometheus", + Path: "dashboard.json", + Inputs: []dashboardimport.ImportDashboardInput{ + {Name: "*", Type: "datasource", Value: "prom"}, + }, + User: &models.SignedInUser{UserId: 2, OrgRole: models.ROLE_ADMIN, OrgId: 3}, + FolderId: 5, + } + resp, err := s.ImportDashboard(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "UDdpyzz7z", resp.UID) + + require.NotNil(t, importDashboardArg) + require.Equal(t, int64(3), importDashboardArg.OrgId) + require.Equal(t, int64(2), importDashboardArg.User.UserId) + require.Equal(t, "prometheus", importDashboardArg.Dashboard.PluginId) + require.Equal(t, int64(5), importDashboardArg.Dashboard.FolderId) + + panel := importDashboardArg.Dashboard.Data.Get("panels").GetIndex(0) + require.Equal(t, "prom", panel.Get("datasource").MustString()) + + require.True(t, importLibraryPanelsForDashboard) + require.True(t, connectLibraryPanelsForDashboardCalled) + }) + + t.Run("When importing a non-plugin dashboard should save dashboard and sync library panels", func(t *testing.T) { + var importDashboardArg *dashboards.SaveDashboardDTO + dashboardService := &dashboardServiceMock{ + importDashboardFunc: func(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { + importDashboardArg = dto + return &models.Dashboard{ + Id: 4, + Uid: dto.Dashboard.Uid, + Slug: dto.Dashboard.Slug, + OrgId: 3, + Version: dto.Dashboard.Version, + PluginId: "prometheus", + FolderId: dto.Dashboard.FolderId, + Title: dto.Dashboard.Title, + Data: dto.Dashboard.Data, + }, nil + }, + } + libraryPanelService := &libraryPanelServiceMock{} + s := &ImportDashboardService{ + dashboardService: dashboardService, + libraryPanelService: libraryPanelService, + } + + dash, err := loadTestDashboard(context.Background(), "", "dashboard.json") + require.NoError(t, err) + + req := &dashboardimport.ImportDashboardRequest{ + Dashboard: dash.Data, + Path: "plugin_dashboard.json", + Inputs: []dashboardimport.ImportDashboardInput{ + {Name: "*", Type: "datasource", Value: "prom"}, + }, + User: &models.SignedInUser{UserId: 2, OrgRole: models.ROLE_ADMIN, OrgId: 3}, + FolderId: 5, + } + resp, err := s.ImportDashboard(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, "UDdpyzz7z", resp.UID) + + require.NotNil(t, importDashboardArg) + require.Equal(t, int64(3), importDashboardArg.OrgId) + require.Equal(t, int64(2), importDashboardArg.User.UserId) + require.Equal(t, "", importDashboardArg.Dashboard.PluginId) + require.Equal(t, int64(5), importDashboardArg.Dashboard.FolderId) + + panel := importDashboardArg.Dashboard.Data.Get("panels").GetIndex(0) + require.Equal(t, "prom", panel.Get("datasource").MustString()) + }) +} + +func loadTestDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, 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)) + if err != nil { + return nil, err + } + + dashboardJSON, err := simplejson.NewJson(bytes) + if err != nil { + return nil, err + } + + return models.NewDashboardFromJson(dashboardJSON), nil +} + +type pluginDashboardManagerMock struct { + plugins.PluginDashboardManager + loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) +} + +func (m *pluginDashboardManagerMock) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { + if m.loadPluginDashboardFunc != nil { + return m.loadPluginDashboardFunc(ctx, pluginID, path) + } + + return nil, nil +} + +type dashboardServiceMock struct { + dashboards.DashboardService + importDashboardFunc func(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) +} + +func (s *dashboardServiceMock) ImportDashboard(ctx context.Context, dto *dashboards.SaveDashboardDTO) (*models.Dashboard, error) { + if s.importDashboardFunc != nil { + return s.importDashboardFunc(ctx, dto) + } + + return nil, nil +} + +type libraryPanelServiceMock struct { + librarypanels.Service + connectLibraryPanelsForDashboardFunc func(ctx context.Context, signedInUser *models.SignedInUser, dash *models.Dashboard) error + importLibraryPanelsForDashboardFunc func(ctx context.Context, signedInUser *models.SignedInUser, dash *models.Dashboard, folderID int64) error +} + +func (s *libraryPanelServiceMock) ConnectLibraryPanelsForDashboard(ctx context.Context, signedInUser *models.SignedInUser, dash *models.Dashboard) error { + if s.connectLibraryPanelsForDashboardFunc != nil { + return s.connectLibraryPanelsForDashboardFunc(ctx, signedInUser, dash) + } + + return nil +} + +func (s *libraryPanelServiceMock) ImportLibraryPanelsForDashboard(ctx context.Context, signedInUser *models.SignedInUser, dash *models.Dashboard, folderID int64) error { + if s.importLibraryPanelsForDashboardFunc != nil { + return s.importLibraryPanelsForDashboardFunc(ctx, signedInUser, dash, folderID) + } + + return nil +} diff --git a/pkg/services/dashboardimport/service/testdata/dashboard.json b/pkg/services/dashboardimport/service/testdata/dashboard.json new file mode 100644 index 00000000000..6a8393dfbfa --- /dev/null +++ b/pkg/services/dashboardimport/service/testdata/dashboard.json @@ -0,0 +1,220 @@ +{ + "__inputs": [ + { + "name": "DS_GDEV-PROMETHEUS", + "label": "gdev-prometheus", + "description": "", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + } + ], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "8.1.0-pre" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 1, + "id": null, + "links": [ + { + "icon": "info", + "tags": [], + "targetBlank": true, + "title": "Grafana Docs", + "tooltip": "", + "type": "link", + "url": "https://grafana.com/docs/grafana/latest/" + }, + { + "icon": "info", + "tags": [], + "targetBlank": true, + "title": "Prometheus Docs", + "type": "link", + "url": "http://prometheus.io/docs/introduction/overview/" + } + ], + "panels": [ + { + "datasource": "${DS_GDEV-PROMETHEUS}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "links": [], + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "prometheus" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#C15C17", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "{instance=\"localhost:9090\",job=\"prometheus\"}" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#CCA300", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 6, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 3, + "links": [], + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "pluginVersion": "8.1.0-pre", + "targets": [ + { + "expr": "sum(irate(prometheus_tsdb_head_samples_appended_total{job=\"prometheus\"}[5m]))", + "format": "time_series", + "hide": false, + "interval": "", + "intervalFactor": 2, + "legendFormat": "samples", + "metric": "", + "refId": "A", + "step": 20 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Samples Appended", + "type": "timeseries" + } + ], + "refresh": "1m", + "revision": "1.0", + "schemaVersion": 30, + "style": "dark", + "tags": ["prometheus"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "now": true, + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "browser", + "title": "Prometheus 2.0 Stats", + "uid": "UDdpyzz7z", + "version": 1 +} diff --git a/pkg/plugins/manager/dashboard_import.go b/pkg/services/dashboardimport/utils/dash_template_evaluator.go similarity index 85% rename from pkg/plugins/manager/dashboard_import.go rename to pkg/services/dashboardimport/utils/dash_template_evaluator.go index 28600b731c6..7c02e033a59 100644 --- a/pkg/plugins/manager/dashboard_import.go +++ b/pkg/services/dashboardimport/utils/dash_template_evaluator.go @@ -1,4 +1,4 @@ -package manager +package utils import ( "encoding/json" @@ -7,7 +7,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/expr" - "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/dashboardimport" ) var varRegex = regexp.MustCompile(`(\$\{.+?\})`) @@ -22,12 +22,19 @@ func (e DashboardInputMissingError) Error() string { type DashTemplateEvaluator struct { template *simplejson.Json - inputs []plugins.ImportDashboardInput + inputs []dashboardimport.ImportDashboardInput variables map[string]string result *simplejson.Json } -func (e *DashTemplateEvaluator) findInput(varName string, varType string) *plugins.ImportDashboardInput { +func NewDashTemplateEvaluator(template *simplejson.Json, inputs []dashboardimport.ImportDashboardInput) *DashTemplateEvaluator { + return &DashTemplateEvaluator{ + template: template, + inputs: inputs, + } +} + +func (e *DashTemplateEvaluator) findInput(varName string, varType string) *dashboardimport.ImportDashboardInput { for _, input := range e.inputs { if varType == input.Type && (input.Name == varName || input.Name == "*") { return &input @@ -50,7 +57,7 @@ func (e *DashTemplateEvaluator) Eval() (*simplejson.Json, error) { // force expressions value to `__expr__` if inputDefJson.Get("pluginId").MustString() == expr.DatasourceType { - input = &plugins.ImportDashboardInput{ + input = &dashboardimport.ImportDashboardInput{ Value: expr.DatasourceType, } } diff --git a/pkg/services/dashboardimport/utils/dash_template_evaluator_test.go b/pkg/services/dashboardimport/utils/dash_template_evaluator_test.go new file mode 100644 index 00000000000..9d6053228a4 --- /dev/null +++ b/pkg/services/dashboardimport/utils/dash_template_evaluator_test.go @@ -0,0 +1,39 @@ +package utils + +import ( + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/stretchr/testify/require" +) + +func TestDashTemplateEvaluator(t *testing.T) { + template, err := simplejson.NewJson([]byte(`{ + "__inputs": [ + { + "name": "DS_NAME", + "type": "datasource" + } + ], + "test": { + "prop": "${DS_NAME}_${DS_NAME}" + } + }`)) + require.NoError(t, err) + + evaluator := &DashTemplateEvaluator{ + template: template, + inputs: []dashboardimport.ImportDashboardInput{ + {Name: "*", Type: "datasource", Value: "my-server"}, + }, + } + + res, err := evaluator.Eval() + require.NoError(t, err) + + require.Equal(t, "my-server_my-server", res.GetPath("test", "prop").MustString()) + + inputs := res.Get("__inputs") + require.Nil(t, inputs.Interface()) +} diff --git a/pkg/plugins/plugindashboards/service.go b/pkg/services/plugindashboards/plugindashboards.go similarity index 70% rename from pkg/plugins/plugindashboards/service.go rename to pkg/services/plugindashboards/plugindashboards.go index a7bbd190290..3f468c8c1e0 100644 --- a/pkg/plugins/plugindashboards/service.go +++ b/pkg/services/plugindashboards/plugindashboards.go @@ -8,34 +8,49 @@ import ( "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/sqlstore" ) -func ProvideService(pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager, - sqlStore *sqlstore.SQLStore) *Service { - s := &Service{ - sqlStore: sqlStore, - pluginStore: pluginStore, - pluginDashboardManager: pluginDashboardManager, - logger: log.New("plugindashboards"), - } - bus.AddEventListener(s.handlePluginStateChanged) +type pluginSettingsStore interface { + GetPluginSettings(ctx context.Context, orgID int64) ([]*models.PluginSettingInfoDTO, error) +} + +func ProvideService(sqlStore *sqlstore.SQLStore, bus bus.Bus, pluginStore plugins.Store, + pluginDashboardManager plugins.PluginDashboardManager, dashboardImportService dashboardimport.Service) *Service { + s := new(sqlStore, bus, pluginStore, pluginDashboardManager, dashboardImportService) s.updateAppDashboards() return s } +func new(pluginSettingsStore pluginSettingsStore, bus bus.Bus, pluginStore plugins.Store, + pluginDashboardManager plugins.PluginDashboardManager, dashboardImportService dashboardimport.Service) *Service { + s := &Service{ + pluginSettingsStore: pluginSettingsStore, + bus: bus, + pluginStore: pluginStore, + pluginDashboardManager: pluginDashboardManager, + dashboardImportService: dashboardImportService, + logger: log.New("plugindashboards"), + } + bus.AddEventListener(s.handlePluginStateChanged) + + return s +} + type Service struct { - sqlStore *sqlstore.SQLStore + pluginSettingsStore pluginSettingsStore + bus bus.Bus pluginStore plugins.Store pluginDashboardManager plugins.PluginDashboardManager - - logger log.Logger + dashboardImportService dashboardimport.Service + logger log.Logger } func (s *Service) updateAppDashboards() { s.logger.Debug("Looking for app dashboard updates") - pluginSettings, err := s.sqlStore.GetPluginSettings(context.Background(), 0) + pluginSettings, err := s.pluginSettingsStore.GetPluginSettings(context.Background(), 0) if err != nil { s.logger.Error("Failed to get all plugin settings", "error", err) return @@ -72,7 +87,7 @@ func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.Plugi s.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug) deleteCmd := models.DeleteDashboardCommand{OrgId: orgID, Id: dash.DashboardId} - if err := bus.Dispatch(ctx, &deleteCmd); err != nil { + if err := s.bus.Dispatch(ctx, &deleteCmd); err != nil { s.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err) return } @@ -91,7 +106,7 @@ func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.Plugi // update version in plugin_setting table to mark that we have processed the update query := models.GetPluginSettingByIdQuery{PluginId: plugin.ID, OrgId: orgID} - if err := bus.Dispatch(ctx, &query); err != nil { + if err := s.bus.Dispatch(ctx, &query); err != nil { s.logger.Error("Failed to read plugin setting by ID", "error", err) return } @@ -103,7 +118,7 @@ func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.Plugi PluginVersion: plugin.Info.Version, } - if err := bus.Dispatch(ctx, &cmd); err != nil { + if err := s.bus.Dispatch(ctx, &cmd); err != nil { s.logger.Error("Failed to update plugin setting version", "error", err) } } @@ -120,14 +135,14 @@ func (s *Service) handlePluginStateChanged(ctx context.Context, event *models.Pl s.syncPluginDashboards(ctx, p, event.OrgId) } else { query := models.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId} - if err := bus.Dispatch(ctx, &query); err != nil { + 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 := bus.Dispatch(ctx, &deleteCmd); err != nil { + if err := s.bus.Dispatch(ctx, &deleteCmd); err != nil { return err } } @@ -143,8 +158,14 @@ func (s *Service) autoUpdateAppDashboard(ctx context.Context, pluginDashInfo *pl } s.logger.Info("Auto updating App dashboard", "dashboard", dash.Title, "newRev", pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision) - user := &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN} - _, _, err = s.pluginDashboardManager.ImportDashboard(ctx, pluginDashInfo.PluginId, pluginDashInfo.Path, orgID, 0, dash.Data, true, - nil, user) + _, 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 } diff --git a/pkg/services/plugindashboards/plugindashboards_test.go b/pkg/services/plugindashboards/plugindashboards_test.go new file mode 100644 index 00000000000..204d1cb3959 --- /dev/null +++ b/pkg/services/plugindashboards/plugindashboards_test.go @@ -0,0 +1,575 @@ +package plugindashboards + +import ( + "context" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/dashboardimport" + "github.com/stretchr/testify/require" +) + +func TestService(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() + + require.Len(t, ctx.getPluginSettingsArgs, 1) + require.Equal(t, int64(0), ctx.getPluginSettingsArgs[0]) + require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.importDashboardArgs) + }) + + scenario(t, "Without any stored enabled plugin shouldn't delete/import any dashboards", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: false, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + PluginId: "test", + Path: "dashboard.json", + }, + }, + }, func(ctx *scenarioContext) { + ctx.s.updateAppDashboards() + + require.NotEmpty(t, ctx.getPluginSettingsArgs) + require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.importDashboardArgs) + }) + + scenario(t, "With stored enabled plugin, but not installed shouldn't delete/import any dashboards", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: true, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + PluginId: "test", + Path: "dashboard.json", + }, + }, + }, func(ctx *scenarioContext) { + ctx.s.updateAppDashboards() + + require.NotEmpty(t, ctx.getPluginSettingsArgs) + require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.importDashboardArgs) + }) + + scenario(t, "With stored enabled plugin and installed with same version shouldn't delete/import any dashboards", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: true, + PluginVersion: "1.0.0", + }, + }, + installedPlugins: []plugins.PluginDTO{ + { + JSONData: plugins.JSONData{ + Info: plugins.Info{ + Version: "1.0.0", + }, + }, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + PluginId: "test", + Path: "dashboard.json", + }, + }, + }, func(ctx *scenarioContext) { + ctx.s.updateAppDashboards() + + require.NotEmpty(t, ctx.getPluginSettingsArgs) + require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.importDashboardArgs) + }) + + scenario(t, "With stored enabled plugin and installed with different versions, but no dashboard updates shouldn't delete/import dashboards", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: true, + PluginVersion: "1.0.0", + }, + }, + installedPlugins: []plugins.PluginDTO{ + { + JSONData: plugins.JSONData{ + Info: plugins.Info{ + Version: "1.0.1", + }, + }, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + PluginId: "test", + Path: "dashboard.json", + Removed: false, + Revision: 1, + ImportedRevision: 1, + }, + }, + }, func(ctx *scenarioContext) { + ctx.s.updateAppDashboards() + + require.NotEmpty(t, ctx.getPluginSettingsArgs) + require.Empty(t, ctx.deleteDashboardArgs) + require.Empty(t, ctx.importDashboardArgs) + }) + + scenario(t, "With stored enabled plugin and installed with different versions and with dashboard updates should delete/import dashboards", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: true, + PluginVersion: "1.0.0", + OrgId: 2, + }, + }, + installedPlugins: []plugins.PluginDTO{ + { + JSONData: plugins.JSONData{ + ID: "test", + Info: plugins.Info{ + Version: "1.0.1", + }, + }, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + DashboardId: 3, + PluginId: "test", + Path: "removed.json", + Removed: true, + }, + { + DashboardId: 4, + PluginId: "test", + Path: "not-updated.json", + }, + { + DashboardId: 5, + PluginId: "test", + Path: "updated.json", + Revision: 1, + ImportedRevision: 2, + }, + }, + }, func(ctx *scenarioContext) { + ctx.s.updateAppDashboards() + + require.NotEmpty(t, ctx.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.importDashboardArgs, 1) + require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId) + require.Equal(t, "updated.json", ctx.importDashboardArgs[0].Path) + require.Equal(t, int64(2), ctx.importDashboardArgs[0].User.OrgId) + require.Equal(t, models.ROLE_ADMIN, ctx.importDashboardArgs[0].User.OrgRole) + require.Equal(t, int64(0), ctx.importDashboardArgs[0].FolderId) + require.True(t, ctx.importDashboardArgs[0].Overwrite) + + require.Len(t, ctx.getPluginSettingsByIdArgs, 1) + require.Equal(t, int64(2), ctx.getPluginSettingsByIdArgs[0].OrgId) + require.Equal(t, "test", ctx.getPluginSettingsByIdArgs[0].PluginId) + + require.Len(t, ctx.updatePluginSettingVersionArgs, 1) + require.Equal(t, int64(2), ctx.updatePluginSettingVersionArgs[0].OrgId) + require.Equal(t, "test", ctx.updatePluginSettingVersionArgs[0].PluginId) + require.Equal(t, "1.0.1", ctx.updatePluginSettingVersionArgs[0].PluginVersion) + }) + }) + + t.Run("handlePluginStateChanged", func(t *testing.T) { + scenario(t, "When app plugin is disabled that doesn't have any imported dashboards shouldn't delete any", + scenarioInput{}, func(ctx *scenarioContext) { + err := ctx.bus.Publish(context.Background(), &models.PluginStateChangedEvent{ + PluginId: "test", + OrgId: 2, + Enabled: false, + }) + 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) + }) + }) + + scenario(t, "When app plugin is disabled that have imported dashboards should delete them", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: true, + OrgId: 2, + }, + }, + installedPlugins: []plugins.PluginDTO{ + { + JSONData: plugins.JSONData{ + ID: "test", + }, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + DashboardId: 3, + PluginId: "test", + Path: "dashboard1.json", + }, + { + DashboardId: 4, + PluginId: "test", + Path: "dashboard2.json", + }, + { + DashboardId: 5, + PluginId: "test", + Path: "dashboard3.json", + }, + }, + }, func(ctx *scenarioContext) { + err := ctx.bus.Publish(context.Background(), &models.PluginStateChangedEvent{ + PluginId: "test", + OrgId: 2, + Enabled: false, + }) + 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) + }) + + scenario(t, "When app plugin is enabled, stored disabled plugin and with dashboard updates should import dashboards", + scenarioInput{ + storedPluginSettings: []*models.PluginSettingInfoDTO{ + { + PluginId: "test", + Enabled: false, + OrgId: 2, + PluginVersion: "1.0.0", + }, + }, + installedPlugins: []plugins.PluginDTO{ + { + JSONData: plugins.JSONData{ + ID: "test", + Info: plugins.Info{ + Version: "1.0.0", + }, + }, + }, + }, + pluginDashboards: []*plugins.PluginDashboardInfoDTO{ + { + DashboardId: 3, + PluginId: "test", + Path: "dashboard1.json", + Revision: 1, + ImportedRevision: 0, + }, + { + DashboardId: 4, + PluginId: "test", + Path: "dashboard2.json", + Revision: 1, + ImportedRevision: 0, + }, + { + DashboardId: 5, + PluginId: "test", + Path: "dashboard3.json", + Revision: 1, + ImportedRevision: 0, + }, + }, + }, func(ctx *scenarioContext) { + err := ctx.bus.Publish(context.Background(), &models.PluginStateChangedEvent{ + PluginId: "test", + OrgId: 2, + Enabled: true, + }) + require.NoError(t, err) + + require.Empty(t, ctx.deleteDashboardArgs) + + require.Len(t, ctx.importDashboardArgs, 3) + require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId) + require.Equal(t, "dashboard1.json", ctx.importDashboardArgs[0].Path) + require.Equal(t, int64(2), ctx.importDashboardArgs[0].User.OrgId) + require.Equal(t, models.ROLE_ADMIN, ctx.importDashboardArgs[0].User.OrgRole) + require.Equal(t, int64(0), ctx.importDashboardArgs[0].FolderId) + require.True(t, ctx.importDashboardArgs[0].Overwrite) + + require.Equal(t, "test", ctx.importDashboardArgs[1].PluginId) + require.Equal(t, "dashboard2.json", ctx.importDashboardArgs[1].Path) + require.Equal(t, int64(2), ctx.importDashboardArgs[1].User.OrgId) + require.Equal(t, models.ROLE_ADMIN, ctx.importDashboardArgs[1].User.OrgRole) + require.Equal(t, int64(0), ctx.importDashboardArgs[1].FolderId) + require.True(t, ctx.importDashboardArgs[1].Overwrite) + + require.Equal(t, "test", ctx.importDashboardArgs[2].PluginId) + require.Equal(t, "dashboard3.json", ctx.importDashboardArgs[2].Path) + require.Equal(t, int64(2), ctx.importDashboardArgs[2].User.OrgId) + require.Equal(t, models.ROLE_ADMIN, ctx.importDashboardArgs[2].User.OrgRole) + require.Equal(t, int64(0), ctx.importDashboardArgs[2].FolderId) + require.True(t, ctx.importDashboardArgs[2].Overwrite) + + require.Len(t, ctx.getPluginSettingsByIdArgs, 1) + require.Equal(t, int64(2), ctx.getPluginSettingsByIdArgs[0].OrgId) + require.Equal(t, "test", ctx.getPluginSettingsByIdArgs[0].PluginId) + + require.Len(t, ctx.updatePluginSettingVersionArgs, 1) + require.Equal(t, int64(2), ctx.updatePluginSettingVersionArgs[0].OrgId) + require.Equal(t, "test", ctx.updatePluginSettingVersionArgs[0].PluginId) + require.Equal(t, "1.0.0", ctx.updatePluginSettingVersionArgs[0].PluginVersion) + }) +} + +type pluginSettingsStoreMock struct { + getPluginSettingsFunc func(ctx context.Context, orgID int64) ([]*models.PluginSettingInfoDTO, error) +} + +func (m *pluginSettingsStoreMock) GetPluginSettings(ctx context.Context, orgID int64) ([]*models.PluginSettingInfoDTO, error) { + if m.getPluginSettingsFunc != nil { + return m.getPluginSettingsFunc(ctx, orgID) + } + + return nil, nil +} + +type pluginStoreMock struct { + plugins.Store + pluginFunc func(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) +} + +func (m *pluginStoreMock) Plugin(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) { + if m.pluginFunc != nil { + return m.pluginFunc(ctx, pluginID) + } + + 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) +} + +func (m *pluginDashboardManagerMock) GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { + if m.getPluginDashboardsFunc != nil { + return m.getPluginDashboardsFunc(ctx, orgID, pluginID) + } + + return []*plugins.PluginDashboardInfoDTO{}, nil +} + +func (m *pluginDashboardManagerMock) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { + if m.loadPluginDashboardFunc != nil { + return m.loadPluginDashboardFunc(ctx, pluginID, path) + } + + return nil, nil +} + +type importDashboardServiceMock struct { + dashboardimport.Service + importDashboardFunc func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) +} + +func (m *importDashboardServiceMock) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { + if m.importDashboardFunc != nil { + return m.importDashboardFunc(ctx, req) + } + + return nil, nil +} + +type scenarioInput struct { + storedPluginSettings []*models.PluginSettingInfoDTO + installedPlugins []plugins.PluginDTO + pluginDashboards []*plugins.PluginDashboardInfoDTO +} + +type scenarioContext struct { + t *testing.T + bus bus.Bus + pluginSettingsStore pluginSettingsStore + getPluginSettingsArgs []int64 + 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 +} + +func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenarioContext)) { + t.Helper() + + sCtx := &scenarioContext{ + t: t, + bus: bus.New(), + getPluginSettingsArgs: []int64{}, + importDashboardArgs: []*dashboardimport.ImportDashboardRequest{}, + deleteDashboardArgs: []*models.DeleteDashboardCommand{}, + getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{}, + updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{}, + getDashboardsByPluginIdQueryArgs: []*models.GetDashboardsByPluginIdQuery{}, + } + + getPluginSettings := func(_ context.Context, orgID int64) ([]*models.PluginSettingInfoDTO, error) { + sCtx.getPluginSettingsArgs = append(sCtx.getPluginSettingsArgs, orgID) + return input.storedPluginSettings, nil + } + + sCtx.pluginSettingsStore = &pluginSettingsStoreMock{ + getPluginSettingsFunc: getPluginSettings, + } + + getPlugin := func(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) { + for _, p := range input.installedPlugins { + if p.ID == pluginID { + return p, true + } + } + + return plugins.PluginDTO{}, false + } + + sCtx.pluginStore = &pluginStoreMock{ + pluginFunc: getPlugin, + } + + getPluginDashboards := func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { + dashboards := []*plugins.PluginDashboardInfoDTO{} + + for _, d := range input.pluginDashboards { + if d.PluginId == pluginID { + dashboards = append(dashboards, d) + } + } + + return dashboards, nil + } + + loadPluginDashboard := func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) { + for _, d := range input.pluginDashboards { + if d.PluginId == pluginID && path == d.Path { + return &models.Dashboard{}, nil + } + } + + return nil, fmt.Errorf("no match for loading plugin dashboard") + } + + sCtx.pluginDashboardManager = &pluginDashboardManagerMock{ + getPluginDashboardsFunc: getPluginDashboards, + loadPluginDashboardFunc: loadPluginDashboard, + } + + importDashboard := func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) { + sCtx.importDashboardArgs = append(sCtx.importDashboardArgs, req) + + return &dashboardimport.ImportDashboardResponse{ + PluginId: req.PluginId, + }, nil + } + + sCtx.importDashboardService = &importDashboardServiceMock{ + 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.GetPluginSettingByIdQuery) error { + for _, p := range input.storedPluginSettings { + if p.PluginId == query.PluginId { + query.Result = &models.PluginSetting{ + PluginId: p.PluginId, + OrgId: p.OrgId, + } + } + } + + sCtx.getPluginSettingsByIdArgs = append(sCtx.getPluginSettingsByIdArgs, query) + return nil + }) + + sCtx.bus.AddHandler(func(ctx context.Context, cmd *models.UpdatePluginSettingVersionCmd) error { + sCtx.updatePluginSettingVersionArgs = append(sCtx.updatePluginSettingVersionArgs, 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 = new(sCtx.pluginSettingsStore, sCtx.bus, sCtx.pluginStore, sCtx.pluginDashboardManager, sCtx.importDashboardService) + + t.Cleanup(bus.ClearBusHandlers) + + t.Run(desc, func(t *testing.T) { + f(sCtx) + }) +} diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 3c799f9f804..7570afe0c5a 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -12,10 +12,10 @@ import ( "path/filepath" "testing" - "github.com/grafana/grafana/pkg/api/dtos" "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/search" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -46,7 +46,7 @@ func TestDashboardQuota(t *testing.T) { dashboardDataOne, err := simplejson.NewJson([]byte(`{"title":"just testing"}`)) require.NoError(t, err) buf1 := &bytes.Buffer{} - err = json.NewEncoder(buf1).Encode(dtos.ImportDashboardCommand{ + err = json.NewEncoder(buf1).Encode(dashboardimport.ImportDashboardRequest{ Dashboard: dashboardDataOne, }) require.NoError(t, err) @@ -71,7 +71,7 @@ func TestDashboardQuota(t *testing.T) { dashboardDataOne, err := simplejson.NewJson([]byte(`{"title":"just testing"}`)) require.NoError(t, err) buf1 := &bytes.Buffer{} - err = json.NewEncoder(buf1).Encode(dtos.ImportDashboardCommand{ + err = json.NewEncoder(buf1).Encode(dashboardimport.ImportDashboardRequest{ Dashboard: dashboardDataOne, }) require.NoError(t, err) diff --git a/pkg/web/webtest/webtest.go b/pkg/web/webtest/webtest.go new file mode 100644 index 00000000000..d7c480c9b28 --- /dev/null +++ b/pkg/web/webtest/webtest.go @@ -0,0 +1,128 @@ +package webtest + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/web" +) + +var requests = map[string]*models.ReqContext{} + +type Server struct { + t testing.TB + Mux *web.Mux + RouteRegister routing.RouteRegister + TestServer *httptest.Server +} + +// NewServer starts and returns a new server. +func NewServer(t testing.TB, routeRegister routing.RouteRegister) *Server { + t.Helper() + + m := web.New() + initCtx := &models.ReqContext{} + m.Use(func(c *web.Context) { + initCtx.Context = c + initCtx.Logger = log.New("api-test") + c.Map(initCtx) + }) + + m.Use(requestContextMiddleware()) + + routeRegister.Register(m.Router) + testServer := httptest.NewServer(m) + t.Cleanup(testServer.Close) + + return &Server{ + t: t, + RouteRegister: routeRegister, + Mux: m, + TestServer: testServer, + } +} + +// NewRequest creates a new request setup for test. +func (s *Server) NewRequest(method string, target string, body io.Reader) *http.Request { + s.t.Helper() + + if !strings.HasPrefix(target, "/") { + target = "/" + target + } + + target = s.TestServer.URL + target + req := httptest.NewRequest(method, target, body) + reqID := generateRequestIdentifier() + req = requestWithRequestIdentifier(req, reqID) + req.RequestURI = "" + return req +} + +// Send sends an 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) +} + +func generateRequestIdentifier() string { + return uuid.NewString() +} + +func requestWithRequestIdentifier(req *http.Request, id string) *http.Request { + req.Header.Set("X-GRAFANA-WEB-TEST-ID", id) + return req +} + +func requestIdentifierFromRequest(req *http.Request) string { + return req.Header.Get("X-GRAFANA-WEB-TEST-ID") +} + +func RequestWithWebContext(req *http.Request, c *models.ReqContext) *http.Request { + reqID := requestIdentifierFromRequest(req) + requests[reqID] = c + return req +} + +func RequestWithSignedInUser(req *http.Request, user *models.SignedInUser) *http.Request { + return RequestWithWebContext(req, &models.ReqContext{ + SignedInUser: &models.SignedInUser{}, + IsSignedIn: true, + }) +} + +func requestContextFromRequest(req *http.Request) *models.ReqContext { + reqID := requestIdentifierFromRequest(req) + val, exists := requests[reqID] + if !exists { + return nil + } + + return val +} + +func requestContextMiddleware() web.Handler { + return func(res http.ResponseWriter, req *http.Request, c *models.ReqContext) { + ctx := requestContextFromRequest(req) + if ctx == nil { + c.Next() + return + } + + c.SignedInUser = ctx.SignedInUser + c.UserToken = ctx.UserToken + c.IsSignedIn = ctx.IsSignedIn + c.IsRenderCall = ctx.IsRenderCall + c.AllowAnonymous = ctx.AllowAnonymous + c.SkipCache = ctx.SkipCache + c.RequestNonce = ctx.RequestNonce + c.PerfmonTimer = ctx.PerfmonTimer + c.LookupTokenErr = ctx.LookupTokenErr + c.Map(c) + } +} diff --git a/pkg/web/webtest/webtest_test.go b/pkg/web/webtest/webtest_test.go new file mode 100644 index 00000000000..4a5bee8a71b --- /dev/null +++ b/pkg/web/webtest/webtest_test.go @@ -0,0 +1,66 @@ +package webtest + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" +) + +func TestServerClient(t *testing.T) { + routeRegister := routing.NewRouteRegister() + routeRegister.Get("/test", routing.Wrap(func(c *models.ReqContext) response.Response { + return response.JSON(http.StatusOK, c.SignedInUser) + })) + + s := NewServer(t, routeRegister) + + t.Run("Making a request with user 1 should return user 1 as signed in user", func(t *testing.T) { + req := s.NewRequest(http.MethodGet, "/test", nil) + req = RequestWithWebContext(req, &models.ReqContext{ + SignedInUser: &models.SignedInUser{ + UserId: 1, + }, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + bytes, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + var user *models.SignedInUser + err = json.Unmarshal(bytes, &user) + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, int64(1), user.UserId) + }) + + t.Run("Making a request with user 2 should return user 2 as signed in user", func(t *testing.T) { + req := s.NewRequest(http.MethodGet, "/test", nil) + req = RequestWithWebContext(req, &models.ReqContext{ + SignedInUser: &models.SignedInUser{ + UserId: 2, + }, + }) + resp, err := s.Send(req) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, http.StatusOK, resp.StatusCode) + bytes, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + var user *models.SignedInUser + err = json.Unmarshal(bytes, &user) + require.NoError(t, err) + require.NotNil(t, user) + require.Equal(t, int64(2), user.UserId) + }) +}