diff --git a/conf/defaults.ini b/conf/defaults.ini index ac000daa2f0..009a70e8335 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -878,6 +878,8 @@ app_tls_skip_verify_insecure = false # Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. allow_loading_unsigned_plugins = marketplace_url = https://grafana.com/grafana/plugins/ +# Enable or disable the Marketplace app which can be used to manage plugins from within Grafana. +marketplace_app_enabled = false #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] diff --git a/conf/sample.ini b/conf/sample.ini index 357a682a4da..eb5fafbd0c6 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -864,6 +864,8 @@ # Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. ;allow_loading_unsigned_plugins = ;marketplace_url = https://grafana.com/grafana/plugins/ +# Enable or disable the Marketplace app which can be used to manage plugins from within Grafana. +;marketplace_app_enabled = false #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] diff --git a/docs/sources/administration/configuration.md b/docs/sources/administration/configuration.md index 8bf45053b0a..a9d59a82644 100644 --- a/docs/sources/administration/configuration.md +++ b/docs/sources/administration/configuration.md @@ -1471,6 +1471,14 @@ Enter a comma-separated list of plugin identifiers to identify plugins that are Custom install/learn more url for enterprise plugins. Defaults to https://grafana.com/grafana/plugins/. +### marketplace_app_enabled + +> **Note:** Available in Grafana 8.0 and later versions. + +Available to Grafana administrators only, the plugin marketplace app is set to `false` by default. Set it to `true` to enable the app. + +For more information, refer to [Plugin marketplace]({{< relref "../plugins/marketplace.md" >}}). +
## [plugin.grafana-image-renderer] diff --git a/docs/sources/plugins/installation.md b/docs/sources/plugins/installation.md index 50ed6d95722..d4711fa0928 100644 --- a/docs/sources/plugins/installation.md +++ b/docs/sources/plugins/installation.md @@ -23,6 +23,8 @@ Follow the instructions on the Install tab. You can either install the plugin wi For more information about Grafana CLI plugin commands, refer to [Plugin commands]({{< relref "../administration/cli.md#plugins-commands" >}}). +As of Grafana v8.0, Marketplace for Grafana was introduced in order to make managing plugins easier. For more information, refer to [Plugin marketplace]({{< relref "./marketplace.md" >}}). + ### Install a packaged plugin After the user has downloaded the archive containing the plugin assets, they can install it by extracting the archive into their plugin directory. diff --git a/docs/sources/plugins/marketplace.md b/docs/sources/plugins/marketplace.md new file mode 100644 index 00000000000..f4ff076a6f6 --- /dev/null +++ b/docs/sources/plugins/marketplace.md @@ -0,0 +1,22 @@ ++++ +title = "Plugin marketplace" +aliases = ["/docs/grafana/latest/plugins/marketplace/"] +weight = 1 ++++ + +# Plugin marketplace + +Marketplace for Grafana is a plugin bundled with Grafana versions 8.0+. It allows users to browse and manage plugins from within Grafana. Only Grafana Admins can access and use the Marketplace. + +[screenshot placeholder] + +To use the Marketplace for Grafana, you first need to enable it in the Grafana [configuration]({{< relref "../administration/configuration.md#marketplace_app_enabled" >}}). + +## Install a plugin from the Marketplace +To install a plugin ... + +### Updating a plugin +To update a plugin ... + +## Uninstall a plugin from the Marketplace +To uninstall a plugin ... diff --git a/pkg/api/api.go b/pkg/api/api.go index 1b39d9f0fa7..9eabb625651 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -282,7 +282,14 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Get("/plugins/:pluginId/health", routing.Wrap(hs.CheckHealth)) apiRoute.Any("/plugins/:pluginId/resources", hs.CallResource) apiRoute.Any("/plugins/:pluginId/resources/*", hs.CallResource) - apiRoute.Any("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList)) + apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList)) + + if hs.Cfg.MarketplaceAppEnabled { + apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { + pluginRoute.Post("/:pluginId/install", bind(dtos.InstallPluginCommand{}), routing.Wrap(hs.InstallPlugin)) + pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin)) + }, reqGrafanaAdmin) + } apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { pluginRoute.Get("/:pluginId/dashboards/", routing.Wrap(hs.GetPluginDashboards)) diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 4b83e443c1d..15ff6938b2d 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -66,3 +66,7 @@ type ImportDashboardCommand struct { Inputs []plugins.ImportDashboardInput `json:"inputs"` FolderId int64 `json:"folderId"` } + +type InstallPluginCommand struct { + Version string `json:"version"` +} diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index caf8e14306f..76406c6833f 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -9,7 +9,6 @@ import ( "sort" "strings" - "github.com/grafana/grafana/pkg/setting" "gopkg.in/macaron.v1" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -19,6 +18,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins/manager/installer" + "github.com/grafana/grafana/pkg/setting" ) func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { @@ -370,6 +371,56 @@ func (hs *HTTPServer) GetPluginErrorsList(_ *models.ReqContext) response.Respons return response.JSON(200, hs.PluginManager.ScanningErrors()) } +func (hs *HTTPServer) InstallPlugin(c *models.ReqContext, dto dtos.InstallPluginCommand) response.Response { + pluginID := c.Params("pluginId") + + err := hs.PluginManager.Install(c.Req.Context(), pluginID, dto.Version) + if err != nil { + var dupeErr plugins.DuplicatePluginError + if errors.As(err, &dupeErr) { + return response.Error(http.StatusConflict, "Plugin already installed", err) + } + var versionUnsupportedErr installer.ErrVersionUnsupported + if errors.As(err, &versionUnsupportedErr) { + return response.Error(http.StatusConflict, "Plugin version not supported", err) + } + var versionNotFoundErr installer.ErrVersionNotFound + if errors.As(err, &versionNotFoundErr) { + return response.Error(http.StatusNotFound, "Plugin version not found", err) + } + if errors.Is(err, installer.ErrPluginNotFound) { + return response.Error(http.StatusNotFound, "Plugin not found", err) + } + if errors.Is(err, plugins.ErrInstallCorePlugin) { + return response.Error(http.StatusForbidden, "Cannot install or change a Core plugin", err) + } + + return response.Error(http.StatusInternalServerError, "Failed to install plugin", err) + } + + return response.JSON(http.StatusOK, []byte{}) +} + +func (hs *HTTPServer) UninstallPlugin(c *models.ReqContext) response.Response { + pluginID := c.Params("pluginId") + + err := hs.PluginManager.Uninstall(c.Req.Context(), pluginID) + if err != nil { + if errors.Is(err, plugins.ErrPluginNotInstalled) { + return response.Error(http.StatusNotFound, "Plugin not installed", err) + } + if errors.Is(err, plugins.ErrUninstallCorePlugin) { + return response.Error(http.StatusForbidden, "Cannot uninstall a Core plugin", err) + } + if errors.Is(err, plugins.ErrUninstallOutsideOfPluginDir) { + return response.Error(http.StatusForbidden, "Cannot uninstall a plugin outside of the plugins directory", err) + } + + return response.Error(http.StatusInternalServerError, "Failed to uninstall plugin", err) + } + return response.JSON(http.StatusOK, []byte{}) +} + func translatePluginRequestErrorToAPIError(err error) response.Response { if errors.Is(err, backendplugin.ErrPluginNotRegistered) { return response.Error(404, "Plugin not found", err) diff --git a/pkg/cmd/grafana-cli/commands/install_command.go b/pkg/cmd/grafana-cli/commands/install_command.go index 2cb857c510b..074443eacd5 100644 --- a/pkg/cmd/grafana-cli/commands/install_command.go +++ b/pkg/cmd/grafana-cli/commands/install_command.go @@ -3,6 +3,7 @@ package commands import ( "archive/zip" "bytes" + "context" "errors" "fmt" "io" @@ -60,7 +61,7 @@ func (cmd Command) installCommand(c utils.CommandLine) error { skipTLSVerify := c.Bool("insecure") i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger) - return i.Install(pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL()) + return i.Install(context.Background(), pluginID, version, c.PluginDirectory(), c.PluginURL(), c.PluginRepoURL()) } // InstallPlugin downloads the plugin code as a zip file from the Grafana.com API diff --git a/pkg/cmd/grafana-server/main.go b/pkg/cmd/grafana-server/main.go index ce73c52aefc..22ce254773a 100644 --- a/pkg/cmd/grafana-server/main.go +++ b/pkg/cmd/grafana-server/main.go @@ -38,7 +38,7 @@ import ( ) // The following variables cannot be constants, since they can be overridden through the -X link flag -var version = "5.0.0" +var version = "7.5.0" var commit = "NA" var buildBranch = "main" var buildstamp string diff --git a/pkg/infra/usagestats/usage_stats_test.go b/pkg/infra/usagestats/usage_stats_test.go index 5b4798ca4a0..ca8e3f73ab4 100644 --- a/pkg/infra/usagestats/usage_stats_test.go +++ b/pkg/infra/usagestats/usage_stats_test.go @@ -549,15 +549,15 @@ type fakePluginManager struct { panels map[string]*plugins.PanelPlugin } -func (pm fakePluginManager) DataSourceCount() int { +func (pm *fakePluginManager) DataSourceCount() int { return len(pm.dataSources) } -func (pm fakePluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { +func (pm *fakePluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { return pm.dataSources[id] } -func (pm fakePluginManager) PanelCount() int { +func (pm *fakePluginManager) PanelCount() int { return len(pm.panels) } diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go index 243f801d294..84b6d6b8111 100644 --- a/pkg/plugins/app_plugin.go +++ b/pkg/plugins/app_plugin.go @@ -1,6 +1,7 @@ package plugins import ( + "context" "encoding/json" "path/filepath" "strings" @@ -71,7 +72,7 @@ func (app *AppPlugin) Load(decoder *json.Decoder, base *PluginBase, backendPlugi cmd := ComposePluginStartCommand(app.Executable) fullpath := filepath.Join(base.PluginDir, cmd) factory := grpcplugin.NewBackendPlugin(app.Id, fullpath, grpcplugin.PluginStartFuncs{}) - if err := backendPluginManager.Register(app.Id, factory); err != nil { + if err := backendPluginManager.RegisterAndStart(context.Background(), app.Id, factory); err != nil { return nil, errutil.Wrapf(err, "failed to register backend plugin") } } diff --git a/pkg/plugins/backendplugin/coreplugin/core_plugin.go b/pkg/plugins/backendplugin/coreplugin/core_plugin.go index 8fe9a697423..01bd1ea5688 100644 --- a/pkg/plugins/backendplugin/coreplugin/core_plugin.go +++ b/pkg/plugins/backendplugin/coreplugin/core_plugin.go @@ -68,6 +68,14 @@ func (cp *corePlugin) Exited() bool { return false } +func (cp *corePlugin) Decommission() error { + return nil +} + +func (cp *corePlugin) IsDecommissioned() bool { + return false +} + func (cp *corePlugin) CollectMetrics(ctx context.Context) (*backend.CollectMetricsResult, error) { return nil, backendplugin.ErrMethodNotImplemented } diff --git a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go index 77b1e8ec2cf..deed574c9b6 100644 --- a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go +++ b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go @@ -19,12 +19,13 @@ type pluginClient interface { } type grpcPlugin struct { - descriptor PluginDescriptor - clientFactory func() *plugin.Client - client *plugin.Client - pluginClient pluginClient - logger log.Logger - mutex sync.RWMutex + descriptor PluginDescriptor + clientFactory func() *plugin.Client + client *plugin.Client + pluginClient pluginClient + logger log.Logger + mutex sync.RWMutex + decommissioned bool } // newPlugin allocates and returns a new gRPC (external) backendplugin.Plugin. @@ -100,6 +101,19 @@ func (p *grpcPlugin) Exited() bool { return true } +func (p *grpcPlugin) Decommission() error { + p.mutex.RLock() + defer p.mutex.RUnlock() + + p.decommissioned = true + + return nil +} + +func (p *grpcPlugin) IsDecommissioned() bool { + return p.decommissioned +} + func (p *grpcPlugin) getPluginClient() (pluginClient, bool) { p.mutex.RLock() if p.client == nil || p.client.Exited() || p.pluginClient == nil { diff --git a/pkg/plugins/backendplugin/ifaces.go b/pkg/plugins/backendplugin/ifaces.go index 6d81c861361..713e6027e64 100644 --- a/pkg/plugins/backendplugin/ifaces.go +++ b/pkg/plugins/backendplugin/ifaces.go @@ -10,8 +10,14 @@ import ( // Manager manages backend plugins. type Manager interface { - // Register registers a backend plugin + //Register registers a backend plugin Register(pluginID string, factory PluginFactoryFunc) error + // RegisterAndStart registers and starts a backend plugin + RegisterAndStart(ctx context.Context, pluginID string, factory PluginFactoryFunc) error + // UnregisterAndStop unregisters and stops a backend plugin + UnregisterAndStop(ctx context.Context, pluginID string) error + // IsRegistered checks if a plugin is registered with the manager + IsRegistered(pluginID string) bool // StartPlugin starts a non-managed backend plugin StartPlugin(ctx context.Context, pluginID string) error // CollectMetrics collects metrics from a registered backend plugin. @@ -35,6 +41,8 @@ type Plugin interface { Stop(ctx context.Context) error IsManaged() bool Exited() bool + Decommission() error + IsDecommissioned() bool backend.CollectMetricsHandler backend.CheckHealthHandler backend.CallResourceHandler diff --git a/pkg/plugins/backendplugin/manager/manager.go b/pkg/plugins/backendplugin/manager/manager.go index 17b021449f7..71de05f321f 100644 --- a/pkg/plugins/backendplugin/manager/manager.go +++ b/pkg/plugins/backendplugin/manager/manager.go @@ -47,7 +47,6 @@ func (m *manager) Init() error { } func (m *manager) Run(ctx context.Context) error { - m.start(ctx) <-ctx.Done() m.stop(ctx) return ctx.Err() @@ -96,8 +95,60 @@ func (m *manager) Register(pluginID string, factory backendplugin.PluginFactoryF return nil } +// RegisterAndStart registers and starts a backend plugin +func (m *manager) RegisterAndStart(ctx context.Context, pluginID string, factory backendplugin.PluginFactoryFunc) error { + err := m.Register(pluginID, factory) + if err != nil { + return err + } + + p, exists := m.Get(pluginID) + if !exists { + return fmt.Errorf("backend plugin %s is not registered", pluginID) + } + + m.start(ctx, p) + + return nil +} + +// UnregisterAndStop unregisters and stops a backend plugin +func (m *manager) UnregisterAndStop(ctx context.Context, pluginID string) error { + m.logger.Debug("Unregistering backend plugin", "pluginId", pluginID) + m.pluginsMu.Lock() + defer m.pluginsMu.Unlock() + + p, exists := m.plugins[pluginID] + if !exists { + return fmt.Errorf("backend plugin %s is not registered", pluginID) + } + + m.logger.Debug("Stopping backend plugin process", "pluginId", pluginID) + if err := p.Decommission(); err != nil { + return err + } + + if err := p.Stop(ctx); err != nil { + return err + } + + delete(m.plugins, pluginID) + + m.logger.Debug("Backend plugin unregistered", "pluginId", pluginID) + return nil +} + +func (m *manager) IsRegistered(pluginID string) bool { + p, _ := m.Get(pluginID) + + return p != nil && !p.IsDecommissioned() +} + func (m *manager) Get(pluginID string) (backendplugin.Plugin, bool) { + m.pluginsMu.RLock() p, ok := m.plugins[pluginID] + m.pluginsMu.RUnlock() + return p, ok } @@ -115,31 +166,27 @@ func (m *manager) getAWSEnvironmentVariables() []string { //nolint: staticcheck // plugins.DataPlugin deprecated func (m *manager) GetDataPlugin(pluginID string) interface{} { - plugin := m.plugins[pluginID] - if plugin == nil { + p, _ := m.Get(pluginID) + + if p == nil { return nil } - if dataPlugin, ok := plugin.(plugins.DataPlugin); ok { + if dataPlugin, ok := p.(plugins.DataPlugin); ok { return dataPlugin } return nil } -// start starts all managed backend plugins -func (m *manager) start(ctx context.Context) { - m.pluginsMu.RLock() - defer m.pluginsMu.RUnlock() - for _, p := range m.plugins { - if !p.IsManaged() { - continue - } +// start starts a managed backend plugin +func (m *manager) start(ctx context.Context, p backendplugin.Plugin) { + if !p.IsManaged() { + return + } - if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil { - p.Logger().Error("Failed to start plugin", "error", err) - continue - } + if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil { + p.Logger().Error("Failed to start plugin", "error", err) } } @@ -435,6 +482,11 @@ func restartKilledProcess(ctx context.Context, p backendplugin.Plugin) error { } return nil case <-ticker.C: + if p.IsDecommissioned() { + p.Logger().Debug("Plugin decommissioned") + return nil + } + if !p.Exited() { continue } diff --git a/pkg/plugins/backendplugin/manager/manager_test.go b/pkg/plugins/backendplugin/manager/manager_test.go index d23631a3d71..81ae11809c2 100644 --- a/pkg/plugins/backendplugin/manager/manager_test.go +++ b/pkg/plugins/backendplugin/manager/manager_test.go @@ -48,14 +48,17 @@ func TestManager(t *testing.T) { ctx.cfg.BuildVersion = "7.0.0" t.Run("Should be able to register plugin", func(t *testing.T) { - err := ctx.manager.Register(testPluginID, ctx.factory) + err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory) require.NoError(t, err) require.NotNil(t, ctx.plugin) require.Equal(t, testPluginID, ctx.plugin.pluginID) require.NotNil(t, ctx.plugin.logger) + require.Equal(t, 1, ctx.plugin.startCount) + require.True(t, ctx.manager.IsRegistered(testPluginID)) t.Run("Should not be able to register an already registered plugin", func(t *testing.T) { - err := ctx.manager.Register(testPluginID, ctx.factory) + err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory) + require.Equal(t, 1, ctx.plugin.startCount) require.Error(t, err) }) @@ -113,7 +116,7 @@ func TestManager(t *testing.T) { wgRun.Wait() require.Equal(t, context.Canceled, runErr) require.Equal(t, 1, ctx.plugin.stopCount) - require.Equal(t, 2, ctx.plugin.startCount) + require.Equal(t, 1, ctx.plugin.startCount) }) t.Run("Shouldn't be able to start managed plugin", func(t *testing.T) { @@ -191,6 +194,21 @@ func TestManager(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) }) }) + + t.Run("Should be able to decommission a running plugin", func(t *testing.T) { + require.True(t, ctx.manager.IsRegistered(testPluginID)) + + err := ctx.manager.UnregisterAndStop(context.Background(), testPluginID) + require.NoError(t, err) + + require.Equal(t, 2, ctx.plugin.stopCount) + require.False(t, ctx.manager.IsRegistered(testPluginID)) + p := ctx.manager.plugins[testPluginID] + require.Nil(t, p) + + err = ctx.manager.StartPlugin(context.Background(), testPluginID) + require.Equal(t, backendplugin.ErrPluginNotRegistered, err) + }) }) }) }) @@ -202,8 +220,9 @@ func TestManager(t *testing.T) { ctx.cfg.BuildVersion = "7.0.0" t.Run("Should be able to register plugin", func(t *testing.T) { - err := ctx.manager.Register(testPluginID, ctx.factory) + err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory) require.NoError(t, err) + require.True(t, ctx.manager.IsRegistered(testPluginID)) require.False(t, ctx.plugin.managed) t.Run("When manager runs should not start plugin", func(t *testing.T) { @@ -259,7 +278,7 @@ func TestManager(t *testing.T) { ctx.cfg.BuildVersion = "7.0.0" ctx.cfg.EnterpriseLicensePath = "/license.txt" - err := ctx.manager.Register(testPluginID, ctx.factory) + err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory) require.NoError(t, err) t.Run("Should provide expected host environment variables", func(t *testing.T) { @@ -317,12 +336,13 @@ func newManagerScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *m } type testPlugin struct { - pluginID string - logger log.Logger - startCount int - stopCount int - managed bool - exited bool + pluginID string + logger log.Logger + startCount int + stopCount int + managed bool + exited bool + decommissioned bool backend.CollectMetricsHandlerFunc backend.CheckHealthHandlerFunc backend.CallResourceHandlerFunc @@ -362,6 +382,21 @@ func (tp *testPlugin) Exited() bool { return tp.exited } +func (tp *testPlugin) Decommission() error { + tp.mutex.Lock() + defer tp.mutex.Unlock() + + tp.decommissioned = true + + return nil +} + +func (tp *testPlugin) IsDecommissioned() bool { + tp.mutex.RLock() + defer tp.mutex.RUnlock() + return tp.decommissioned +} + func (tp *testPlugin) kill() { tp.mutex.Lock() defer tp.mutex.Unlock() diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index eb74b0e3970..62a005c5135 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -51,7 +51,7 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, base *PluginBase, backend OnLegacyStart: p.onLegacyPluginStart, OnStart: p.onPluginStart, }) - if err := backendPluginManager.Register(p.Id, factory); err != nil { + if err := backendPluginManager.RegisterAndStart(context.Background(), p.Id, factory); err != nil { return nil, errutil.Wrapf(err, "failed to register backend plugin") } } diff --git a/pkg/plugins/frontend_plugin.go b/pkg/plugins/frontend_plugin.go index 0de13b9e81c..229fb05b8ee 100644 --- a/pkg/plugins/frontend_plugin.go +++ b/pkg/plugins/frontend_plugin.go @@ -31,7 +31,7 @@ func (fp *FrontendPluginBase) InitFrontendPlugin(cfg *setting.Cfg) []*PluginStat fp.Info.Logos.Large = getPluginLogoUrl(fp.Type, fp.Info.Logos.Large, fp.BaseUrl) for i := 0; i < len(fp.Info.Screenshots); i++ { - fp.Info.Screenshots[i].Path = evalRelativePluginUrlPath(fp.Info.Screenshots[i].Path, fp.BaseUrl) + fp.Info.Screenshots[i].Path = evalRelativePluginUrlPath(fp.Info.Screenshots[i].Path, fp.BaseUrl, fp.Type) } return staticRoutes @@ -39,10 +39,14 @@ func (fp *FrontendPluginBase) InitFrontendPlugin(cfg *setting.Cfg) []*PluginStat func getPluginLogoUrl(pluginType, path, baseUrl string) string { if path == "" { - return "public/img/icn-" + pluginType + ".svg" + return defaultLogoPath(pluginType) } - return evalRelativePluginUrlPath(path, baseUrl) + return evalRelativePluginUrlPath(path, baseUrl, pluginType) +} + +func defaultLogoPath(pluginType string) string { + return "public/img/icn-" + pluginType + ".svg" } func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin, cfg *setting.Cfg) { @@ -79,7 +83,7 @@ func isExternalPlugin(pluginDir string, cfg *setting.Cfg) bool { return !strings.Contains(pluginDir, cfg.StaticRootPath) } -func evalRelativePluginUrlPath(pathStr string, baseUrl string) string { +func evalRelativePluginUrlPath(pathStr, baseUrl, pluginType string) string { if pathStr == "" { return "" } @@ -88,5 +92,11 @@ func evalRelativePluginUrlPath(pathStr string, baseUrl string) string { if u.IsAbs() { return pathStr } + + // is set as default or has already been prefixed with base path + if pathStr == defaultLogoPath(pluginType) || strings.HasPrefix(pathStr, baseUrl) { + return pathStr + } + return path.Join(baseUrl, pathStr) } diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index c884e15c2b0..ff695a0057f 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -2,7 +2,6 @@ package plugins import ( "context" - "os" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -57,6 +56,10 @@ type Manager interface { LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error) // IsAppInstalled returns whether an app is installed. IsAppInstalled(id string) bool + // Install installs a plugin. + Install(ctx context.Context, pluginID, version string) error + // Uninstall uninstalls a plugin. + Uninstall(ctx context.Context, pluginID string) error } type ImportDashboardInput struct { @@ -75,10 +78,9 @@ type DataRequestHandler interface { type PluginInstaller interface { // Install finds the plugin given the provided information // and installs in the provided plugins directory. - Install(pluginID, version, pluginsDirectory, pluginZipURL, pluginRepoURL string) error + Install(ctx context.Context, pluginID, version, pluginsDirectory, pluginZipURL, pluginRepoURL string) error // Uninstall removes the specified plugin from the provided plugins directory. - Uninstall(pluginID, pluginPath string) error - DownloadFile(pluginID string, tmpFile *os.File, url string, checksum string) error + Uninstall(ctx context.Context, pluginID, pluginPath string) error } type PluginInstallerLogger interface { diff --git a/pkg/plugins/manager/dashboards.go b/pkg/plugins/manager/dashboards.go index 4c35284f82b..4e651c3417e 100644 --- a/pkg/plugins/manager/dashboards.go +++ b/pkg/plugins/manager/dashboards.go @@ -11,8 +11,8 @@ import ( ) func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { - plugin, exists := pm.plugins[pluginID] - if !exists { + plugin := pm.GetPlugin(pluginID) + if plugin == nil { return nil, plugins.PluginNotFoundError{PluginID: pluginID} } @@ -71,8 +71,8 @@ func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*p } func (pm *PluginManager) LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error) { - plugin, exists := pm.plugins[pluginID] - if !exists { + plugin := pm.GetPlugin(pluginID) + if plugin == nil { return nil, plugins.PluginNotFoundError{PluginID: pluginID} } diff --git a/pkg/plugins/manager/installer/installer.go b/pkg/plugins/manager/installer/installer.go index f1f0c8cd78d..52184d79b85 100644 --- a/pkg/plugins/manager/installer/installer.go +++ b/pkg/plugins/manager/installer/installer.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bufio" "bytes" + "context" "crypto/sha256" "crypto/tls" "encoding/json" @@ -40,8 +41,8 @@ const ( ) var ( - ErrNotFoundError = errors.New("404 not found error") - reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/") + ErrPluginNotFound = errors.New("plugin not found") + reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/") ) type BadRequestError struct { @@ -56,6 +57,35 @@ func (e *BadRequestError) Error() string { return e.Status } +type ErrVersionUnsupported struct { + PluginID string + RequestedVersion string + RecommendedVersion string +} + +func (e ErrVersionUnsupported) Error() string { + if len(e.RecommendedVersion) > 0 { + return fmt.Sprintf("%s v%s is not supported on your architecture and OS, latest suitable version is %s", + e.PluginID, e.RequestedVersion, e.RecommendedVersion) + } + return fmt.Sprintf("%s v%s is not supported on your architecture and OS", e.PluginID, e.RequestedVersion) +} + +type ErrVersionNotFound struct { + PluginID string + RequestedVersion string + RecommendedVersion string +} + +func (e ErrVersionNotFound) Error() string { + if len(e.RecommendedVersion) > 0 { + return fmt.Sprintf("%s v%s is not supported on your architecture and OS, latest suitable version is %s", + e.PluginID, e.RequestedVersion, e.RecommendedVersion) + } + return fmt.Sprintf("could not find a version %s for %s. The latest suitable version is %s", e.RequestedVersion, + e.PluginID, e.RecommendedVersion) +} + func New(skipTLSVerify bool, grafanaVersion string, logger plugins.PluginInstallerLogger) *Installer { return &Installer{ httpClient: makeHttpClient(skipTLSVerify, 10*time.Second), @@ -67,7 +97,7 @@ func New(skipTLSVerify bool, grafanaVersion string, logger plugins.PluginInstall // Install downloads the plugin code as a zip file from specified URL // and then extracts the zip into the provided plugins directory. -func (i *Installer) Install(pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error { +func (i *Installer) Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error { isInternal := false var checksum string @@ -140,13 +170,13 @@ func (i *Installer) Install(pluginID, version, pluginsDir, pluginZipURL, pluginR res, _ := toPluginDTO(pluginsDir, pluginID) - i.log.Successf("Installed %s v%s successfully", res.ID, res.Info.Version) + i.log.Successf("Downloaded %s v%s zip successfully", res.ID, res.Info.Version) // download dependency plugins for _, dep := range res.Dependencies.Plugins { i.log.Infof("Fetching %s dependencies...", res.ID) - if err := i.Install(dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil { - return errutil.Wrapf(err, "failed to install plugin '%s'", dep.ID) + if err := i.Install(ctx, dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil { + return errutil.Wrapf(err, "failed to install plugin %s", dep.ID) } } @@ -154,7 +184,7 @@ func (i *Installer) Install(pluginID, version, pluginsDir, pluginZipURL, pluginR } // Uninstall removes the specified plugin from the provided plugins directory. -func (i *Installer) Uninstall(pluginID, pluginPath string) error { +func (i *Installer) Uninstall(ctx context.Context, pluginID, pluginPath string) error { pluginDir := filepath.Join(pluginPath, pluginID) // verify it's a plugin directory @@ -253,10 +283,9 @@ func (i *Installer) getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL stri i.log.Debugf("Fetching metadata for plugin \"%s\" from repo %s", pluginID, pluginRepoURL) body, err := i.sendRequestGetBytes(pluginRepoURL, "repo", pluginID) if err != nil { - if errors.Is(err, ErrNotFoundError) { - return Plugin{}, - fmt.Errorf("failed to find plugin \"%s\" in plugin repository. Please check if plugin ID is correct", - pluginID) + if errors.Is(err, ErrPluginNotFound) { + i.log.Errorf("failed to find plugin '%s' in plugin repository. Please check if plugin ID is correct", pluginID) + return Plugin{}, err } return Plugin{}, errutil.Wrap("Failed to send request", err) } @@ -335,7 +364,7 @@ func (i *Installer) createRequest(URL string, subPaths ...string) (*http.Request func (i *Installer) handleResponse(res *http.Response) (io.ReadCloser, error) { if res.StatusCode == 404 { - return nil, ErrNotFoundError + return nil, ErrPluginNotFound } if res.StatusCode/100 != 2 && res.StatusCode/100 != 4 { @@ -405,7 +434,10 @@ func selectVersion(plugin *Plugin, version string) (*Version, error) { latestForArch := latestSupportedVersion(plugin) if latestForArch == nil { - return nil, fmt.Errorf("%s is not supported on your architecture and OS", plugin.ID) + return nil, ErrVersionUnsupported{ + PluginID: plugin.ID, + RequestedVersion: version, + } } if version == "" { @@ -419,14 +451,19 @@ func selectVersion(plugin *Plugin, version string) (*Version, error) { } if len(ver.Version) == 0 { - return nil, fmt.Errorf("could not find a version %s for %s. The latest suitable version is %s", - version, plugin.ID, latestForArch.Version) + return nil, ErrVersionNotFound{ + PluginID: plugin.ID, + RequestedVersion: version, + RecommendedVersion: latestForArch.Version, + } } if !supportsCurrentArch(&ver) { - return nil, fmt.Errorf( - "the version you requested is not supported on your architecture and OS, latest suitable version is %s", - latestForArch.Version) + return nil, ErrVersionUnsupported{ + PluginID: plugin.ID, + RequestedVersion: version, + RecommendedVersion: latestForArch.Version, + } } return &ver, nil diff --git a/pkg/plugins/manager/logger.go b/pkg/plugins/manager/logger.go index 724c0f14b05..dc8fe9145f2 100644 --- a/pkg/plugins/manager/logger.go +++ b/pkg/plugins/manager/logger.go @@ -12,7 +12,7 @@ type InfraLogWrapper struct { debugMode bool } -func New(name string, debugMode bool) (l *InfraLogWrapper) { +func NewInstallerLogger(name string, debugMode bool) (l *InfraLogWrapper) { return &InfraLogWrapper{ debugMode: debugMode, l: log.New(name), diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index b49c33f04d5..2c0d0029925 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -12,6 +12,7 @@ import ( "reflect" "runtime" "strings" + "sync" "time" "github.com/grafana/grafana/pkg/infra/fs" @@ -20,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins/manager/installer" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" @@ -28,7 +30,12 @@ import ( ) var ( - plog log.Logger + plog log.Logger + installerLog = NewInstallerLogger("plugin.installer", true) +) + +const ( + grafanaComURL = "https://grafana.com/api/plugins" ) type unsignedPluginConditionFunc = func(plugin *plugins.PluginBase) bool @@ -48,6 +55,7 @@ type PluginManager struct { BackendPluginManager backendplugin.Manager `inject:""` Cfg *setting.Cfg `inject:""` SQLStore *sqlstore.SQLStore `inject:""` + pluginInstaller plugins.PluginInstaller log log.Logger scanningErrors []error @@ -64,6 +72,7 @@ type PluginManager struct { panels map[string]*plugins.PanelPlugin apps map[string]*plugins.AppPlugin staticRoutes []*plugins.PluginStaticRoute + pluginsMu sync.RWMutex } func init() { @@ -88,6 +97,7 @@ func (pm *PluginManager) Init() error { pm.log = log.New("plugins") plog = log.New("plugins") pm.pluginScanningErrors = map[string]plugins.PluginError{} + pm.pluginInstaller = installer.New(false, pm.Cfg.BuildVersion, installerLog) pm.log.Info("Starting plugin search") @@ -109,11 +119,21 @@ func (pm *PluginManager) Init() error { } } - // check if plugins dir exists - exists, err = fs.Exists(pm.Cfg.PluginsPath) + err = pm.initExternalPlugins() if err != nil { return err } + + return nil +} + +func (pm *PluginManager) initExternalPlugins() error { + // check if plugins dir exists + exists, err := fs.Exists(pm.Cfg.PluginsPath) + if err != nil { + return err + } + if !exists { if err = os.MkdirAll(pm.Cfg.PluginsPath, os.ModePerm); err != nil { pm.log.Error("failed to create external plugins directory", "dir", pm.Cfg.PluginsPath, "error", err) @@ -132,27 +152,29 @@ func (pm *PluginManager) Init() error { return err } - for _, panel := range pm.panels { + var staticRoutesList []*plugins.PluginStaticRoute + for _, panel := range pm.Panels() { staticRoutes := panel.InitFrontendPlugin(pm.Cfg) - pm.staticRoutes = append(pm.staticRoutes, staticRoutes...) + staticRoutesList = append(staticRoutesList, staticRoutes...) } - for _, ds := range pm.dataSources { + for _, ds := range pm.DataSources() { staticRoutes := ds.InitFrontendPlugin(pm.Cfg) - pm.staticRoutes = append(pm.staticRoutes, staticRoutes...) + staticRoutesList = append(staticRoutesList, staticRoutes...) } - for _, app := range pm.apps { + for _, app := range pm.Apps() { staticRoutes := app.InitApp(pm.panels, pm.dataSources, pm.Cfg) - pm.staticRoutes = append(pm.staticRoutes, staticRoutes...) + staticRoutesList = append(staticRoutesList, staticRoutes...) } - if pm.renderer != nil { + if pm.Renderer() != nil { staticRoutes := pm.renderer.InitFrontendPlugin(pm.Cfg) - pm.staticRoutes = append(pm.staticRoutes, staticRoutes...) + staticRoutesList = append(staticRoutesList, staticRoutes...) } + pm.staticRoutes = staticRoutesList - for _, p := range pm.plugins { + for _, p := range pm.Plugins() { if p.IsCorePlugin { p.Signature = plugins.PluginSignatureInternal } else { @@ -182,14 +204,23 @@ func (pm *PluginManager) Run(ctx context.Context) error { } func (pm *PluginManager) Renderer() *plugins.RendererPlugin { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return pm.renderer } func (pm *PluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return pm.dataSources[id] } func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + var rslt []*plugins.DataSourcePlugin for _, ds := range pm.dataSources { rslt = append(rslt, ds) @@ -199,18 +230,30 @@ func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin { } func (pm *PluginManager) DataSourceCount() int { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return len(pm.dataSources) } func (pm *PluginManager) PanelCount() int { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return len(pm.panels) } func (pm *PluginManager) AppCount() int { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return len(pm.apps) } func (pm *PluginManager) Plugins() []*plugins.PluginBase { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + var rslt []*plugins.PluginBase for _, p := range pm.plugins { rslt = append(rslt, p) @@ -220,6 +263,9 @@ func (pm *PluginManager) Plugins() []*plugins.PluginBase { } func (pm *PluginManager) Apps() []*plugins.AppPlugin { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + var rslt []*plugins.AppPlugin for _, p := range pm.apps { rslt = append(rslt, p) @@ -228,11 +274,29 @@ func (pm *PluginManager) Apps() []*plugins.AppPlugin { return rslt } +func (pm *PluginManager) Panels() []*plugins.PanelPlugin { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + + var rslt []*plugins.PanelPlugin + for _, p := range pm.panels { + rslt = append(rslt, p) + } + + return rslt +} + func (pm *PluginManager) GetPlugin(id string) *plugins.PluginBase { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return pm.plugins[id] } func (pm *PluginManager) GetApp(id string) *plugins.AppPlugin { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + return pm.apps[id] } @@ -290,6 +354,25 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error { pm.log.Debug("Initial plugin loading done") + pluginsByID := make(map[string]struct{}) + for scannedPluginPath, scannedPlugin := range scanner.plugins { + // Check if scanning found duplicate plugins + if _, dupe := pluginsByID[scannedPlugin.Id]; dupe { + pm.log.Warn("Skipping plugin as it's a duplicate", "id", scannedPlugin.Id) + scanner.errors = append(scanner.errors, + plugins.DuplicatePluginError{PluginID: scannedPlugin.Id, ExistingPluginDir: scannedPlugin.PluginDir}) + delete(scanner.plugins, scannedPluginPath) + continue + } + pluginsByID[scannedPlugin.Id] = struct{}{} + + // Check if scanning found plugins that are already installed + if existing := pm.GetPlugin(scannedPlugin.Id); existing != nil { + pm.log.Debug("Skipping plugin as it's already installed", "plugin", existing.Id, "version", existing.Info.Version) + delete(scanner.plugins, scannedPluginPath) + } + } + pluginTypes := map[string]interface{}{ "panel": plugins.PanelPlugin{}, "datasource": plugins.DataSourcePlugin{}, @@ -371,7 +454,7 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error { } if len(scanner.errors) > 0 { - pm.log.Warn("Some plugins failed to load", "errors", scanner.errors) + pm.log.Warn("Some plugin scanning errors were found", "errors", scanner.errors) pm.scanningErrors = scanner.errors } @@ -385,6 +468,9 @@ func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugin return err } + pm.pluginsMu.Lock() + defer pm.pluginsMu.Unlock() + var pb *plugins.PluginBase switch p := plug.(type) { case *plugins.DataSourcePlugin: @@ -403,12 +489,6 @@ func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugin panic(fmt.Sprintf("Unrecognized plugin type %T", plug)) } - if p, exists := pm.plugins[pb.Id]; exists { - pm.log.Warn("Plugin is duplicate", "id", pb.Id) - scanner.errors = append(scanner.errors, plugins.DuplicatePluginError{Plugin: pb, ExistingPlugin: p}) - return nil - } - if !strings.HasPrefix(pluginBase.PluginDir, pm.Cfg.StaticRootPath) { pm.log.Info("Registering plugin", "id", pb.Id) } @@ -666,7 +746,10 @@ func collectPluginFilesWithin(rootDir string) ([]string, error) { // GetDataPlugin gets a DataPlugin with a certain name. If none is found, nil is returned. //nolint: staticcheck // plugins.DataPlugin deprecated func (pm *PluginManager) GetDataPlugin(id string) plugins.DataPlugin { - if p, exists := pm.dataSources[id]; exists && p.CanHandleDataQueries() { + pm.pluginsMu.RLock() + defer pm.pluginsMu.RUnlock() + + if p := pm.GetDataSource(id); p != nil && p.CanHandleDataQueries() { return p } @@ -683,3 +766,99 @@ func (pm *PluginManager) GetDataPlugin(id string) plugins.DataPlugin { func (pm *PluginManager) StaticRoutes() []*plugins.PluginStaticRoute { return pm.staticRoutes } + +func (pm *PluginManager) Install(ctx context.Context, pluginID, version string) error { + plugin := pm.GetPlugin(pluginID) + if plugin != nil { + if plugin.IsCorePlugin { + return plugins.ErrInstallCorePlugin + } + + if plugin.Info.Version == version { + return plugins.DuplicatePluginError{ + PluginID: pluginID, + ExistingPluginDir: plugin.PluginDir, + } + } + + // remove existing installation of plugin + err := pm.Uninstall(context.Background(), plugin.Id) + if err != nil { + return err + } + } + + err := pm.pluginInstaller.Install(ctx, pluginID, version, pm.Cfg.PluginsPath, "", grafanaComURL) + if err != nil { + return err + } + + err = pm.initExternalPlugins() + if err != nil { + return err + } + + return nil +} + +func (pm *PluginManager) Uninstall(ctx context.Context, pluginID string) error { + plugin := pm.GetPlugin(pluginID) + if plugin == nil { + return plugins.ErrPluginNotInstalled + } + + if plugin.IsCorePlugin { + return plugins.ErrUninstallCorePlugin + } + + // extra security check to ensure we only remove plugins that are located in the configured plugins directory + path, err := filepath.Rel(pm.Cfg.PluginsPath, plugin.PluginDir) + if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) { + return plugins.ErrUninstallOutsideOfPluginDir + } + + if pm.BackendPluginManager.IsRegistered(pluginID) { + err := pm.BackendPluginManager.UnregisterAndStop(ctx, pluginID) + if err != nil { + return err + } + } + + err = pm.unregister(plugin) + if err != nil { + return err + } + + return pm.pluginInstaller.Uninstall(ctx, pluginID, pm.Cfg.PluginsPath) +} + +func (pm *PluginManager) unregister(plugin *plugins.PluginBase) error { + pm.pluginsMu.Lock() + defer pm.pluginsMu.Unlock() + + switch plugin.Type { + case "panel": + delete(pm.panels, plugin.Id) + case "datasource": + delete(pm.dataSources, plugin.Id) + case "app": + delete(pm.apps, plugin.Id) + case "renderer": + pm.renderer = nil + } + + delete(pm.plugins, plugin.Id) + + pm.removeStaticRoute(plugin.Id) + + return nil +} + +func (pm *PluginManager) removeStaticRoute(pluginID string) { + for i, route := range pm.staticRoutes { + if pluginID == route.PluginId { + pm.staticRoutes = append(pm.staticRoutes[:i], pm.staticRoutes[i+1:]...) + return + } + } +} diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index 7082eb1e30e..030334bfdc8 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -5,8 +5,12 @@ import ( "errors" "fmt" "path/filepath" + "reflect" + "strings" "testing" + "github.com/google/go-cmp/cmp" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" @@ -18,7 +22,33 @@ import ( ) func TestPluginManager_Init(t *testing.T) { - t.Run("Base case", func(t *testing.T) { + t.Run("Base case (core + bundled plugins)", func(t *testing.T) { + staticRootPath, err := filepath.Abs("../../../public") + require.NoError(t, err) + bundledPluginsPath, err := filepath.Abs("../../../plugins-bundled/internal") + require.NoError(t, err) + + pm := createManager(t, func(pm *PluginManager) { + pm.Cfg.PluginsPath = "" + pm.Cfg.BundledPluginsPath = bundledPluginsPath + pm.Cfg.StaticRootPath = staticRootPath + }) + err = pm.Init() + require.NoError(t, err) + + assert.Empty(t, pm.scanningErrors) + verifyCorePluginCatalogue(t, pm) + + // verify bundled plugins + assert.NotNil(t, pm.plugins["input"]) + assert.NotNil(t, pm.dataSources["input"]) + + assert.Len(t, pm.StaticRoutes(), 1) + assert.Equal(t, "input", pm.StaticRoutes()[0].PluginId) + assert.True(t, strings.HasPrefix(pm.StaticRoutes()[0].Directory, bundledPluginsPath+"/input-datasource/")) + }) + + t.Run("Base case with single external plugin", func(t *testing.T) { pm := createManager(t, func(pm *PluginManager) { pm.Cfg.PluginSettings = setting.PluginSettings{ "nginx-app": map[string]string{ @@ -30,10 +60,10 @@ func TestPluginManager_Init(t *testing.T) { require.NoError(t, err) assert.Empty(t, pm.scanningErrors) - assert.Greater(t, len(pm.dataSources), 1) - assert.Greater(t, len(pm.panels), 1) - assert.Equal(t, "app/plugins/datasource/graphite/module", pm.dataSources["graphite"].Module) + verifyCorePluginCatalogue(t, pm) + assert.NotEmpty(t, pm.apps) + assert.Equal(t, "app/plugins/datasource/graphite/module", pm.dataSources["graphite"].Module) assert.Equal(t, "public/plugins/test-app/img/logo_large.png", pm.apps["test-app"].Info.Logos.Large) assert.Equal(t, "public/plugins/test-app/img/screenshot2.png", pm.apps["test-app"].Info.Screenshots[1].Path) }) @@ -44,8 +74,6 @@ func TestPluginManager_Init(t *testing.T) { }) err := pm.Init() require.NoError(t, err) - - assert.Equal(t, []error{fmt.Errorf(`plugin "test" is unsigned`)}, pm.scanningErrors) }) t.Run("With external unsigned back-end plugin and configuration disabling signature check of this plugin", func(t *testing.T) { @@ -106,23 +134,85 @@ func TestPluginManager_Init(t *testing.T) { }) t.Run("With external back-end plugin with valid v2 signature", func(t *testing.T) { + const pluginsDir = "testdata/valid-v2-signature" + const pluginFolder = pluginsDir + "/plugin" pm := createManager(t, func(manager *PluginManager) { - manager.Cfg.PluginsPath = "testdata/valid-v2-signature" + manager.Cfg.PluginsPath = pluginsDir }) err := pm.Init() require.NoError(t, err) require.Empty(t, pm.scanningErrors) - const pluginID = "test" - assert.NotNil(t, pm.plugins[pluginID]) - assert.Equal(t, "datasource", pm.plugins[pluginID].Type) - assert.Equal(t, "Test", pm.plugins[pluginID].Name) - assert.Equal(t, pluginID, pm.plugins[pluginID].Id) - assert.Equal(t, "1.0.0", pm.plugins[pluginID].Info.Version) - assert.Equal(t, plugins.PluginSignatureValid, pm.plugins[pluginID].Signature) - assert.Equal(t, plugins.GrafanaType, pm.plugins[pluginID].SignatureType) - assert.Equal(t, "Grafana Labs", pm.plugins[pluginID].SignatureOrg) - assert.False(t, pm.plugins[pluginID].IsCorePlugin) + // capture manager plugin state + datasources := pm.dataSources + panels := pm.panels + apps := pm.apps + + verifyPluginManagerState := func() { + assert.Empty(t, pm.scanningErrors) + verifyCorePluginCatalogue(t, pm) + + // verify plugin has been loaded successfully + const pluginID = "test" + + if diff := cmp.Diff(&plugins.PluginBase{ + Type: "datasource", + Name: "Test", + State: "alpha", + Id: pluginID, + Info: plugins.PluginInfo{ + Author: plugins.PluginInfoLink{ + Name: "Will Browne", + Url: "https://willbrowne.com", + }, + Description: "Test", + Logos: plugins.PluginLogos{ + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", + }, + Build: plugins.PluginBuildInfo{}, + Version: "1.0.0", + }, + PluginDir: pluginFolder, + Backend: false, + IsCorePlugin: false, + Signature: plugins.PluginSignatureValid, + SignatureType: plugins.GrafanaType, + SignatureOrg: "Grafana Labs", + Dependencies: plugins.PluginDependencies{ + GrafanaVersion: "*", + Plugins: []plugins.PluginDependencyItem{}, + }, + Module: "plugins/test/module", + BaseUrl: "public/plugins/test", + }, pm.plugins[pluginID]); diff != "" { + t.Errorf("result mismatch (-want +got) %s\n", diff) + } + + ds := pm.GetDataSource(pluginID) + assert.NotNil(t, ds) + assert.Equal(t, pluginID, ds.Id) + assert.Equal(t, pm.plugins[pluginID], &ds.FrontendPluginBase.PluginBase) + + assert.Len(t, pm.StaticRoutes(), 1) + assert.Equal(t, pluginID, pm.StaticRoutes()[0].PluginId) + assert.Equal(t, pluginFolder, pm.StaticRoutes()[0].Directory) + } + + verifyPluginManagerState() + + t.Run("Re-initializing external plugins is idempotent", func(t *testing.T) { + err = pm.initExternalPlugins() + require.NoError(t, err) + + // verify plugin state remains the same as previous + verifyPluginManagerState() + + assert.Empty(t, pm.scanningErrors) + assert.True(t, reflect.DeepEqual(datasources, pm.dataSources)) + assert.True(t, reflect.DeepEqual(panels, pm.panels)) + assert.True(t, reflect.DeepEqual(apps, pm.apps)) + }) }) t.Run("With back-end plugin with invalid v2 private signature (mismatched root URL)", func(t *testing.T) { @@ -221,6 +311,173 @@ func TestPluginManager_IsBackendOnlyPlugin(t *testing.T) { } } +func TestPluginManager_Installer(t *testing.T) { + t.Run("Install plugin after manager init", func(t *testing.T) { + fm := &fakeBackendPluginManager{} + pm := createManager(t, func(pm *PluginManager) { + pm.BackendPluginManager = fm + }) + + err := pm.Init() + require.NoError(t, err) + + // mock installer + installer := &fakePluginInstaller{} + pm.pluginInstaller = installer + + // Set plugin location (we do this after manager Init() so that + // it doesn't install the plugin automatically) + pm.Cfg.PluginsPath = "testdata/installer" + + pluginID := "test" + pluginFolder := pm.Cfg.PluginsPath + "/plugin" + + err = pm.Install(context.Background(), pluginID, "1.0.0") + require.NoError(t, err) + + assert.Equal(t, 1, installer.installCount) + assert.Equal(t, 0, installer.uninstallCount) + + // verify plugin manager has loaded core plugins successfully + assert.Empty(t, pm.scanningErrors) + verifyCorePluginCatalogue(t, pm) + + // verify plugin has been loaded successfully + assert.NotNil(t, pm.plugins[pluginID]) + if diff := cmp.Diff(&plugins.PluginBase{ + Type: "datasource", + Name: "Test", + State: "alpha", + Id: pluginID, + Info: plugins.PluginInfo{ + Author: plugins.PluginInfoLink{ + Name: "Will Browne", + Url: "https://willbrowne.com", + }, + Description: "Test", + Logos: plugins.PluginLogos{ + Small: "public/img/icn-datasource.svg", + Large: "public/img/icn-datasource.svg", + }, + Build: plugins.PluginBuildInfo{}, + Version: "1.0.0", + }, + PluginDir: pluginFolder, + Backend: false, + IsCorePlugin: false, + Signature: plugins.PluginSignatureValid, + SignatureType: plugins.GrafanaType, + SignatureOrg: "Grafana Labs", + Dependencies: plugins.PluginDependencies{ + GrafanaVersion: "*", + Plugins: []plugins.PluginDependencyItem{}, + }, + Module: "plugins/test/module", + BaseUrl: "public/plugins/test", + }, pm.plugins[pluginID]); diff != "" { + t.Errorf("result mismatch (-want +got) %s\n", diff) + } + + ds := pm.GetDataSource(pluginID) + assert.NotNil(t, ds) + assert.Equal(t, pluginID, ds.Id) + assert.Equal(t, pm.plugins[pluginID], &ds.FrontendPluginBase.PluginBase) + + assert.Len(t, pm.StaticRoutes(), 1) + assert.Equal(t, pluginID, pm.StaticRoutes()[0].PluginId) + assert.Equal(t, pluginFolder, pm.StaticRoutes()[0].Directory) + + t.Run("Won't install if already installed", func(t *testing.T) { + err := pm.Install(context.Background(), pluginID, "1.0.0") + require.Equal(t, plugins.DuplicatePluginError{ + PluginID: pluginID, + ExistingPluginDir: pluginFolder, + }, err) + }) + + t.Run("Uninstall base case", func(t *testing.T) { + err := pm.Uninstall(context.Background(), pluginID) + require.NoError(t, err) + + assert.Equal(t, 1, installer.installCount) + assert.Equal(t, 1, installer.uninstallCount) + + assert.Nil(t, pm.GetDataSource(pluginID)) + assert.Nil(t, pm.GetPlugin(pluginID)) + assert.Len(t, pm.StaticRoutes(), 0) + + t.Run("Won't uninstall if not installed", func(t *testing.T) { + err := pm.Uninstall(context.Background(), pluginID) + require.Equal(t, plugins.ErrPluginNotInstalled, err) + }) + }) + }) +} + +func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) { + t.Helper() + + panels := []string{ + "alertlist", + "annolist", + "barchart", + "bargauge", + "dashlist", + "debug", + "gauge", + "gettingstarted", + "graph", + "heatmap", + "live", + "logs", + "news", + "nodeGraph", + "piechart", + "pluginlist", + "stat", + "table", + "table-old", + "text", + "timeline", + "timeseries", + "welcome", + "xychart", + } + + datasources := []string{ + "alertmanager", + "stackdriver", + "cloudwatch", + "dashboard", + "elasticsearch", + "grafana", + "grafana-azure-monitor-datasource", + "graphite", + "influxdb", + "jaeger", + "loki", + "mixed", + "mssql", + "mysql", + "opentsdb", + "postgres", + "prometheus", + "tempo", + "testdata", + "zipkin", + } + + for _, p := range panels { + assert.NotNil(t, pm.plugins[p]) + assert.NotNil(t, pm.panels[p]) + } + + for _, ds := range datasources { + assert.NotNil(t, pm.plugins[ds]) + assert.NotNil(t, pm.dataSources[ds]) + } +} + type fakeBackendPluginManager struct { backendplugin.Manager @@ -232,6 +489,33 @@ func (f *fakeBackendPluginManager) Register(pluginID string, factory backendplug return nil } +func (f *fakeBackendPluginManager) RegisterAndStart(ctx context.Context, pluginID string, factory backendplugin.PluginFactoryFunc) error { + f.registeredPlugins = append(f.registeredPlugins, pluginID) + return nil +} + +func (f *fakeBackendPluginManager) UnregisterAndStop(ctx context.Context, pluginID string) error { + var result []string + + for _, existingPlugin := range f.registeredPlugins { + if pluginID != existingPlugin { + result = append(result, pluginID) + } + } + + f.registeredPlugins = result + return nil +} + +func (f *fakeBackendPluginManager) IsRegistered(pluginID string) bool { + for _, existingPlugin := range f.registeredPlugins { + if pluginID == existingPlugin { + return true + } + } + return false +} + func (f *fakeBackendPluginManager) StartPlugin(ctx context.Context, pluginID string) error { return nil } @@ -247,6 +531,21 @@ func (f *fakeBackendPluginManager) CheckHealth(ctx context.Context, pCtx backend func (f *fakeBackendPluginManager) CallResource(pluginConfig backend.PluginContext, ctx *models.ReqContext, path string) { } +type fakePluginInstaller struct { + installCount int + uninstallCount int +} + +func (f *fakePluginInstaller) Install(ctx context.Context, pluginID, version, pluginsDirectory, pluginZipURL, pluginRepoURL string) error { + f.installCount++ + return nil +} + +func (f *fakePluginInstaller) Uninstall(ctx context.Context, pluginID, pluginPath string) error { + f.uninstallCount++ + return nil +} + func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager { t.Helper() diff --git a/pkg/plugins/manager/queries.go b/pkg/plugins/manager/queries.go index 630dc56563f..f47a7314e2b 100644 --- a/pkg/plugins/manager/queries.go +++ b/pkg/plugins/manager/queries.go @@ -16,7 +16,7 @@ func (pm *PluginManager) GetPluginSettings(orgID int64) (map[string]*models.Plug pluginMap[plug.PluginId] = plug } - for _, pluginDef := range pm.plugins { + for _, pluginDef := range pm.Plugins() { // ignore entries that exists if _, ok := pluginMap[pluginDef.Id]; ok { continue @@ -63,8 +63,8 @@ func (pm *PluginManager) GetEnabledPlugins(orgID int64) (*plugins.EnabledPlugins return enabledPlugins, err } - for pluginID, app := range pm.apps { - if b, ok := pluginSettingMap[pluginID]; ok { + for _, app := range pm.Apps() { + if b, ok := pluginSettingMap[app.Id]; ok { app.Pinned = b.Pinned enabledPlugins.Apps = append(enabledPlugins.Apps, app) } diff --git a/pkg/plugins/manager/testdata/installer/plugin/MANIFEST.txt b/pkg/plugins/manager/testdata/installer/plugin/MANIFEST.txt new file mode 100644 index 00000000000..72699594e8b --- /dev/null +++ b/pkg/plugins/manager/testdata/installer/plugin/MANIFEST.txt @@ -0,0 +1,27 @@ + +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +{ + "manifestVersion": "2.0.0", + "signatureType": "grafana", + "signedByOrg": "grafana", + "signedByOrgName": "Grafana Labs", + "plugin": "test", + "version": "1.0.0", + "time": 1605807330546, + "keyId": "7e4d0c6a708866e7", + "files": { + "plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f" + } +} +-----BEGIN PGP SIGNATURE----- +Version: OpenPGP.js v4.10.1 +Comment: https://openpgpjs.org + +wqEEARMKAAYFAl+2rOIACgkQfk0ManCIZudNOwIJAT8FTzwnRFCSLTOaR3F3 +2Fh96eRbghokXcQG9WqpQAg8ZiVfGXeWWRNtV+nuQ9VOZOTO0BovWLuMkym2 +ci8ABpWOAgd46LkGn3Dd8XVnGmLI6UPqHAXflItOrCMRiGcYJn5PxP1aCz8h +D0JoNI9TIKrhMtM4voU3Qhf3mIOTHueuDNS48w== +=mu2j +-----END PGP SIGNATURE----- diff --git a/pkg/plugins/manager/testdata/installer/plugin/plugin.json b/pkg/plugins/manager/testdata/installer/plugin/plugin.json new file mode 100644 index 00000000000..31e38a2be85 --- /dev/null +++ b/pkg/plugins/manager/testdata/installer/plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "type": "datasource", + "name": "Test", + "id": "test", + "backend": true, + "executable": "test", + "state": "alpha", + "info": { + "version": "1.0.0", + "description": "Test", + "author": { + "name": "Will Browne", + "url": "https://willbrowne.com" + } + } +} diff --git a/pkg/plugins/manager/update_checker.go b/pkg/plugins/manager/update_checker.go index e789149ea49..3b20af0ad4f 100644 --- a/pkg/plugins/manager/update_checker.go +++ b/pkg/plugins/manager/update_checker.go @@ -71,7 +71,7 @@ func (pm *PluginManager) checkForUpdates() { return } - for _, plug := range pm.plugins { + for _, plug := range pm.Plugins() { for _, gplug := range gNetPlugins { if gplug.Slug == plug.Id { plug.GrafanaNetVersion = gplug.Version diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 4c1f2dea677..e3210643609 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -2,6 +2,7 @@ package plugins import ( "encoding/json" + "errors" "fmt" "github.com/grafana/grafana/pkg/models" @@ -13,21 +14,28 @@ const ( PluginTypeDashboard = "dashboard" ) +var ( + ErrInstallCorePlugin = errors.New("cannot install a Core plugin") + ErrUninstallCorePlugin = errors.New("cannot uninstall a Core plugin") + ErrUninstallOutsideOfPluginDir = errors.New("cannot uninstall a plugin outside") + ErrPluginNotInstalled = errors.New("plugin is not installed") +) + type PluginNotFoundError struct { PluginID string } func (e PluginNotFoundError) Error() string { - return fmt.Sprintf("plugin with ID %q not found", e.PluginID) + return fmt.Sprintf("plugin with ID '%s' not found", e.PluginID) } type DuplicatePluginError struct { - Plugin *PluginBase - ExistingPlugin *PluginBase + PluginID string + ExistingPluginDir string } func (e DuplicatePluginError) Error() string { - return fmt.Sprintf("plugin with ID %q already loaded from %q", e.Plugin.Id, e.ExistingPlugin.PluginDir) + return fmt.Sprintf("plugin with ID '%s' already exists in '%s'", e.PluginID, e.ExistingPluginDir) } func (e DuplicatePluginError) Is(err error) bool { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 22a8c9e79f0..ca7ef20801f 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -257,6 +257,7 @@ type Cfg struct { PluginSettings PluginSettings PluginsAllowUnsigned []string MarketplaceURL string + MarketplaceAppEnabled bool DisableSanitizeHtml bool EnterpriseLicensePath string @@ -888,6 +889,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug) } cfg.MarketplaceURL = pluginsSection.Key("marketplace_url").MustString("https://grafana.com/grafana/plugins/") + cfg.MarketplaceAppEnabled = pluginsSection.Key("marketplace_app_enabled").MustBool(false) // Read and populate feature toggles list featureTogglesSection := iniFile.Section("feature_toggles") diff --git a/pkg/tests/api/plugins/api_install_test.go b/pkg/tests/api/plugins/api_install_test.go new file mode 100644 index 00000000000..b7d8e7f7924 --- /dev/null +++ b/pkg/tests/api/plugins/api_install_test.go @@ -0,0 +1,89 @@ +package plugins + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/tests/testinfra" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + usernameAdmin = "admin" + usernameNonAdmin = "nonAdmin" + defaultPassword = "password" +) + +func TestPluginInstallAccess(t *testing.T) { + dir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + MarketplaceAppEnabled: true, + }) + store := testinfra.SetUpDatabase(t, dir) + store.Bus = bus.GetBus() // in order to allow successful user auth + grafanaListedAddr := testinfra.StartGrafana(t, dir, cfgPath, store) + + createUser(t, store, usernameNonAdmin, defaultPassword, false) + createUser(t, store, usernameAdmin, defaultPassword, true) + + t.Run("Request is forbidden if not from an admin", func(t *testing.T) { + statusCode, body := makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/install")) + assert.Equal(t, 403, statusCode) + assert.JSONEq(t, "{\"message\": \"Permission denied\"}", body) + + statusCode, body = makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/uninstall")) + assert.Equal(t, 403, statusCode) + assert.JSONEq(t, "{\"message\": \"Permission denied\"}", body) + }) + + t.Run("Request is not forbidden if from an admin", func(t *testing.T) { + statusCode, body := makePostRequest(t, grafanaAPIURL(usernameAdmin, grafanaListedAddr, "plugins/test/install")) + assert.Equal(t, 404, statusCode) + assert.JSONEq(t, "{\"error\":\"plugin not found\", \"message\":\"Plugin not found\"}", body) + + statusCode, body = makePostRequest(t, grafanaAPIURL(usernameAdmin, grafanaListedAddr, "plugins/test/uninstall")) + assert.Equal(t, 404, statusCode) + assert.JSONEq(t, "{\"error\":\"plugin is not installed\", \"message\":\"Plugin not installed\"}", body) + }) +} + +func createUser(t *testing.T, store *sqlstore.SQLStore, username, password string, isAdmin bool) { + t.Helper() + + cmd := models.CreateUserCommand{ + Login: username, + Password: password, + IsAdmin: isAdmin, + } + _, err := store.CreateUser(context.Background(), cmd) + require.NoError(t, err) +} + +func makePostRequest(t *testing.T, URL string) (int, string) { + t.Helper() + + // nolint:gosec + resp, err := http.Post(URL, "application/json", bytes.NewBufferString("")) + require.NoError(t, err) + t.Cleanup(func() { + _ = resp.Body.Close() + log.Warn("Failed to close response body", "err", err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(b) +} + +func grafanaAPIURL(username string, grafanaListedAddr string, path string) string { + return fmt.Sprintf("http://%s:%s@%s/api/%s", username, defaultPassword, grafanaListedAddr, path) +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 106f3dc05b5..77fab42cdfb 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -231,6 +231,12 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = anonSect.NewKey("enabled", "false") require.NoError(t, err) } + if o.MarketplaceAppEnabled { + anonSect, err := cfg.NewSection("plugins") + require.NoError(t, err) + _, err = anonSect.NewKey("marketplace_app_enabled", "true") + require.NoError(t, err) + } } cfgPath := filepath.Join(cfgDir, "test.ini") @@ -244,9 +250,10 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { } type GrafanaOpts struct { - EnableCSP bool - EnableFeatureToggles []string - AnonymousUserRole models.RoleType - EnableQuota bool - DisableAnonymous bool + EnableCSP bool + EnableFeatureToggles []string + AnonymousUserRole models.RoleType + EnableQuota bool + DisableAnonymous bool + MarketplaceAppEnabled bool } diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index b4eee605ed6..ef7ea48ca3a 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -79,7 +79,7 @@ func (s *CloudWatchService) Init() error { QueryDataHandler: newExecutor(s.LogsService, im, s.Cfg, awsds.NewSessionCache()), }) - if err := s.BackendPluginManager.Register("cloudwatch", factory); err != nil { + if err := s.BackendPluginManager.RegisterAndStart(context.Background(), "cloudwatch", factory); err != nil { plog.Error("Failed to register plugin", "error", err) } return nil diff --git a/pkg/tsdb/testdatasource/testdata.go b/pkg/tsdb/testdatasource/testdata.go index de009cdd1a7..9e9cab79925 100644 --- a/pkg/tsdb/testdatasource/testdata.go +++ b/pkg/tsdb/testdatasource/testdata.go @@ -1,6 +1,7 @@ package testdatasource import ( + "context" "net/http" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -35,7 +36,7 @@ func (p *testDataPlugin) Init() error { CallResourceHandler: httpadapter.New(resourceMux), StreamHandler: newTestStreamHandler(p.logger), }) - err := p.BackendPluginManager.Register("testdata", factory) + err := p.BackendPluginManager.RegisterAndStart(context.Background(), "testdata", factory) if err != nil { p.logger.Error("Failed to register plugin", "error", err) }