Plugins: Enable plugin runtime install/uninstall capabilities (#33836)

* add uninstall flow

* add install flow

* small cleanup

* smaller-footprint solution

* cleanup + make bp start auto

* fix interface contract

* improve naming

* accept version arg

* ensure use of shared logger

* make installer a field

* add plugin decommissioning

* add basic error checking

* fix api docs

* making initialization idempotent

* add mutex

* fix comment

* fix test

* add test for decommission

* improve existing test

* add more test coverage

* more tests

* change test func to use read lock

* refactoring + adding test asserts

* improve purging old install flow

* improve dupe checking

* change log name

* skip over dupe scanned

* make test assertion more flexible

* remove trailing line

* fix pointer receiver name

* update comment

* add context to API

* add config flag

* add base http api test + fix update functionality

* simplify existing check

* clean up test

* refactor tests based on feedback

* add single quotes to errs

* use gcmp in tests + fix logo issue

* make plugin list testing more flexible

* address feedback

* fix API test

* fix linter

* undo preallocate

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* Update docs/sources/administration/configuration.md

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>

* fix linting issue in test

* add docs placeholder

* update install notes

* Update docs/sources/plugins/marketplace.md

Co-authored-by: Marcus Olsson <marcus.olsson@hey.com>

* update access wording

* add more placeholder docs

* add link to more info

* PR feedback - improved errors, refactor, lock fix

* improve err details

* propagate plugin version errors

* don't autostart renderer

* add H1

* fix imports

Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com>
Co-authored-by: Marcus Olsson <marcus.olsson@hey.com>
pull/34021/head
Will Browne 4 years ago committed by GitHub
parent 1fbadab600
commit c39d6ad97d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      conf/defaults.ini
  2. 2
      conf/sample.ini
  3. 8
      docs/sources/administration/configuration.md
  4. 2
      docs/sources/plugins/installation.md
  5. 22
      docs/sources/plugins/marketplace.md
  6. 9
      pkg/api/api.go
  7. 4
      pkg/api/dtos/plugins.go
  8. 53
      pkg/api/plugins.go
  9. 3
      pkg/cmd/grafana-cli/commands/install_command.go
  10. 2
      pkg/cmd/grafana-server/main.go
  11. 6
      pkg/infra/usagestats/usage_stats_test.go
  12. 3
      pkg/plugins/app_plugin.go
  13. 8
      pkg/plugins/backendplugin/coreplugin/core_plugin.go
  14. 26
      pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go
  15. 10
      pkg/plugins/backendplugin/ifaces.go
  16. 84
      pkg/plugins/backendplugin/manager/manager.go
  17. 57
      pkg/plugins/backendplugin/manager/manager_test.go
  18. 2
      pkg/plugins/datasource_plugin.go
  19. 18
      pkg/plugins/frontend_plugin.go
  20. 10
      pkg/plugins/ifaces.go
  21. 8
      pkg/plugins/manager/dashboards.go
  22. 73
      pkg/plugins/manager/installer/installer.go
  23. 2
      pkg/plugins/manager/logger.go
  24. 217
      pkg/plugins/manager/manager.go
  25. 333
      pkg/plugins/manager/manager_test.go
  26. 6
      pkg/plugins/manager/queries.go
  27. 27
      pkg/plugins/manager/testdata/installer/plugin/MANIFEST.txt
  28. 16
      pkg/plugins/manager/testdata/installer/plugin/plugin.json
  29. 2
      pkg/plugins/manager/update_checker.go
  30. 16
      pkg/plugins/models.go
  31. 2
      pkg/setting/setting.go
  32. 89
      pkg/tests/api/plugins/api_install_test.go
  33. 17
      pkg/tests/testinfra/testinfra.go
  34. 2
      pkg/tsdb/cloudwatch/cloudwatch.go
  35. 3
      pkg/tsdb/testdatasource/testdata.go

@ -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]

@ -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]

@ -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" >}}).
<hr>
## [plugin.grafana-image-renderer]

@ -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.

@ -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 ...

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

@ -66,3 +66,7 @@ type ImportDashboardCommand struct {
Inputs []plugins.ImportDashboardInput `json:"inputs"`
FolderId int64 `json:"folderId"`
}
type InstallPluginCommand struct {
Version string `json:"version"`
}

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

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

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

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

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

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

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

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

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

@ -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()

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

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

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

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

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

@ -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),

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

@ -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()

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

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

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

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

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

@ -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")

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

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

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

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

Loading…
Cancel
Save