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