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>
This commit is contained in:
Will Browne 2021-05-12 20:05:16 +02:00 committed by GitHub
parent 1fbadab600
commit c39d6ad97d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1020 additions and 126 deletions

View File

@ -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. # 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 = allow_loading_unsigned_plugins =
marketplace_url = https://grafana.com/grafana/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 ########################## #################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer] [plugin.grafana-image-renderer]

View File

@ -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. # 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 = ;allow_loading_unsigned_plugins =
;marketplace_url = https://grafana.com/grafana/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 ########################## #################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer] [plugin.grafana-image-renderer]

View File

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

View File

@ -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" >}}). 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 ### 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. After the user has downloaded the archive containing the plugin assets, they can install it by extracting the archive into their plugin directory.

View File

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

View File

@ -282,7 +282,14 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Get("/plugins/:pluginId/health", routing.Wrap(hs.CheckHealth)) 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/: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) { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Get("/:pluginId/dashboards/", routing.Wrap(hs.GetPluginDashboards)) pluginRoute.Get("/:pluginId/dashboards/", routing.Wrap(hs.GetPluginDashboards))

View File

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

View File

@ -9,7 +9,6 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/grafana/grafana/pkg/setting"
"gopkg.in/macaron.v1" "gopkg.in/macaron.v1"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
@ -19,6 +18,8 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin" "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 { 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()) 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 { func translatePluginRequestErrorToAPIError(err error) response.Response {
if errors.Is(err, backendplugin.ErrPluginNotRegistered) { if errors.Is(err, backendplugin.ErrPluginNotRegistered) {
return response.Error(404, "Plugin not found", err) return response.Error(404, "Plugin not found", err)

View File

@ -3,6 +3,7 @@ package commands
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@ -60,7 +61,7 @@ func (cmd Command) installCommand(c utils.CommandLine) error {
skipTLSVerify := c.Bool("insecure") skipTLSVerify := c.Bool("insecure")
i := installer.New(skipTLSVerify, services.GrafanaVersion, services.Logger) 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 // InstallPlugin downloads the plugin code as a zip file from the Grafana.com API

View File

@ -38,7 +38,7 @@ import (
) )
// The following variables cannot be constants, since they can be overridden through the -X link flag // 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 commit = "NA"
var buildBranch = "main" var buildBranch = "main"
var buildstamp string var buildstamp string

View File

@ -549,15 +549,15 @@ type fakePluginManager struct {
panels map[string]*plugins.PanelPlugin panels map[string]*plugins.PanelPlugin
} }
func (pm fakePluginManager) DataSourceCount() int { func (pm *fakePluginManager) DataSourceCount() int {
return len(pm.dataSources) return len(pm.dataSources)
} }
func (pm fakePluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { func (pm *fakePluginManager) GetDataSource(id string) *plugins.DataSourcePlugin {
return pm.dataSources[id] return pm.dataSources[id]
} }
func (pm fakePluginManager) PanelCount() int { func (pm *fakePluginManager) PanelCount() int {
return len(pm.panels) return len(pm.panels)
} }

View File

@ -1,6 +1,7 @@
package plugins package plugins
import ( import (
"context"
"encoding/json" "encoding/json"
"path/filepath" "path/filepath"
"strings" "strings"
@ -71,7 +72,7 @@ func (app *AppPlugin) Load(decoder *json.Decoder, base *PluginBase, backendPlugi
cmd := ComposePluginStartCommand(app.Executable) cmd := ComposePluginStartCommand(app.Executable)
fullpath := filepath.Join(base.PluginDir, cmd) fullpath := filepath.Join(base.PluginDir, cmd)
factory := grpcplugin.NewBackendPlugin(app.Id, fullpath, grpcplugin.PluginStartFuncs{}) 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") return nil, errutil.Wrapf(err, "failed to register backend plugin")
} }
} }

View File

@ -68,6 +68,14 @@ func (cp *corePlugin) Exited() bool {
return false 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) { func (cp *corePlugin) CollectMetrics(ctx context.Context) (*backend.CollectMetricsResult, error) {
return nil, backendplugin.ErrMethodNotImplemented return nil, backendplugin.ErrMethodNotImplemented
} }

View File

@ -19,12 +19,13 @@ type pluginClient interface {
} }
type grpcPlugin struct { type grpcPlugin struct {
descriptor PluginDescriptor descriptor PluginDescriptor
clientFactory func() *plugin.Client clientFactory func() *plugin.Client
client *plugin.Client client *plugin.Client
pluginClient pluginClient pluginClient pluginClient
logger log.Logger logger log.Logger
mutex sync.RWMutex mutex sync.RWMutex
decommissioned bool
} }
// newPlugin allocates and returns a new gRPC (external) backendplugin.Plugin. // newPlugin allocates and returns a new gRPC (external) backendplugin.Plugin.
@ -100,6 +101,19 @@ func (p *grpcPlugin) Exited() bool {
return true 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) { func (p *grpcPlugin) getPluginClient() (pluginClient, bool) {
p.mutex.RLock() p.mutex.RLock()
if p.client == nil || p.client.Exited() || p.pluginClient == nil { if p.client == nil || p.client.Exited() || p.pluginClient == nil {

View File

@ -10,8 +10,14 @@ import (
// Manager manages backend plugins. // Manager manages backend plugins.
type Manager interface { type Manager interface {
// Register registers a backend plugin //Register registers a backend plugin
Register(pluginID string, factory PluginFactoryFunc) error 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 starts a non-managed backend plugin
StartPlugin(ctx context.Context, pluginID string) error StartPlugin(ctx context.Context, pluginID string) error
// CollectMetrics collects metrics from a registered backend plugin. // CollectMetrics collects metrics from a registered backend plugin.
@ -35,6 +41,8 @@ type Plugin interface {
Stop(ctx context.Context) error Stop(ctx context.Context) error
IsManaged() bool IsManaged() bool
Exited() bool Exited() bool
Decommission() error
IsDecommissioned() bool
backend.CollectMetricsHandler backend.CollectMetricsHandler
backend.CheckHealthHandler backend.CheckHealthHandler
backend.CallResourceHandler backend.CallResourceHandler

View File

@ -47,7 +47,6 @@ func (m *manager) Init() error {
} }
func (m *manager) Run(ctx context.Context) error { func (m *manager) Run(ctx context.Context) error {
m.start(ctx)
<-ctx.Done() <-ctx.Done()
m.stop(ctx) m.stop(ctx)
return ctx.Err() return ctx.Err()
@ -96,8 +95,60 @@ func (m *manager) Register(pluginID string, factory backendplugin.PluginFactoryF
return nil 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) { func (m *manager) Get(pluginID string) (backendplugin.Plugin, bool) {
m.pluginsMu.RLock()
p, ok := m.plugins[pluginID] p, ok := m.plugins[pluginID]
m.pluginsMu.RUnlock()
return p, ok return p, ok
} }
@ -115,31 +166,27 @@ func (m *manager) getAWSEnvironmentVariables() []string {
//nolint: staticcheck // plugins.DataPlugin deprecated //nolint: staticcheck // plugins.DataPlugin deprecated
func (m *manager) GetDataPlugin(pluginID string) interface{} { func (m *manager) GetDataPlugin(pluginID string) interface{} {
plugin := m.plugins[pluginID] p, _ := m.Get(pluginID)
if plugin == nil {
if p == nil {
return nil return nil
} }
if dataPlugin, ok := plugin.(plugins.DataPlugin); ok { if dataPlugin, ok := p.(plugins.DataPlugin); ok {
return dataPlugin return dataPlugin
} }
return nil return nil
} }
// start starts all managed backend plugins // start starts a managed backend plugin
func (m *manager) start(ctx context.Context) { func (m *manager) start(ctx context.Context, p backendplugin.Plugin) {
m.pluginsMu.RLock() if !p.IsManaged() {
defer m.pluginsMu.RUnlock() return
for _, p := range m.plugins { }
if !p.IsManaged() {
continue
}
if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil { if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil {
p.Logger().Error("Failed to start plugin", "error", err) p.Logger().Error("Failed to start plugin", "error", err)
continue
}
} }
} }
@ -435,6 +482,11 @@ func restartKilledProcess(ctx context.Context, p backendplugin.Plugin) error {
} }
return nil return nil
case <-ticker.C: case <-ticker.C:
if p.IsDecommissioned() {
p.Logger().Debug("Plugin decommissioned")
return nil
}
if !p.Exited() { if !p.Exited() {
continue continue
} }

View File

@ -48,14 +48,17 @@ func TestManager(t *testing.T) {
ctx.cfg.BuildVersion = "7.0.0" ctx.cfg.BuildVersion = "7.0.0"
t.Run("Should be able to register plugin", func(t *testing.T) { 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.NoError(t, err)
require.NotNil(t, ctx.plugin) require.NotNil(t, ctx.plugin)
require.Equal(t, testPluginID, ctx.plugin.pluginID) require.Equal(t, testPluginID, ctx.plugin.pluginID)
require.NotNil(t, ctx.plugin.logger) 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) { 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) require.Error(t, err)
}) })
@ -113,7 +116,7 @@ func TestManager(t *testing.T) {
wgRun.Wait() wgRun.Wait()
require.Equal(t, context.Canceled, runErr) require.Equal(t, context.Canceled, runErr)
require.Equal(t, 1, ctx.plugin.stopCount) 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) { 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) 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" ctx.cfg.BuildVersion = "7.0.0"
t.Run("Should be able to register plugin", func(t *testing.T) { 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.NoError(t, err)
require.True(t, ctx.manager.IsRegistered(testPluginID))
require.False(t, ctx.plugin.managed) require.False(t, ctx.plugin.managed)
t.Run("When manager runs should not start plugin", func(t *testing.T) { 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.BuildVersion = "7.0.0"
ctx.cfg.EnterpriseLicensePath = "/license.txt" 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) require.NoError(t, err)
t.Run("Should provide expected host environment variables", func(t *testing.T) { 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 { type testPlugin struct {
pluginID string pluginID string
logger log.Logger logger log.Logger
startCount int startCount int
stopCount int stopCount int
managed bool managed bool
exited bool exited bool
decommissioned bool
backend.CollectMetricsHandlerFunc backend.CollectMetricsHandlerFunc
backend.CheckHealthHandlerFunc backend.CheckHealthHandlerFunc
backend.CallResourceHandlerFunc backend.CallResourceHandlerFunc
@ -362,6 +382,21 @@ func (tp *testPlugin) Exited() bool {
return tp.exited 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() { func (tp *testPlugin) kill() {
tp.mutex.Lock() tp.mutex.Lock()
defer tp.mutex.Unlock() defer tp.mutex.Unlock()

View File

@ -51,7 +51,7 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, base *PluginBase, backend
OnLegacyStart: p.onLegacyPluginStart, OnLegacyStart: p.onLegacyPluginStart,
OnStart: p.onPluginStart, 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") return nil, errutil.Wrapf(err, "failed to register backend plugin")
} }
} }

View File

@ -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) fp.Info.Logos.Large = getPluginLogoUrl(fp.Type, fp.Info.Logos.Large, fp.BaseUrl)
for i := 0; i < len(fp.Info.Screenshots); i++ { 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 return staticRoutes
@ -39,10 +39,14 @@ func (fp *FrontendPluginBase) InitFrontendPlugin(cfg *setting.Cfg) []*PluginStat
func getPluginLogoUrl(pluginType, path, baseUrl string) string { func getPluginLogoUrl(pluginType, path, baseUrl string) string {
if path == "" { 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) { 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) return !strings.Contains(pluginDir, cfg.StaticRootPath)
} }
func evalRelativePluginUrlPath(pathStr string, baseUrl string) string { func evalRelativePluginUrlPath(pathStr, baseUrl, pluginType string) string {
if pathStr == "" { if pathStr == "" {
return "" return ""
} }
@ -88,5 +92,11 @@ func evalRelativePluginUrlPath(pathStr string, baseUrl string) string {
if u.IsAbs() { if u.IsAbs() {
return pathStr 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) return path.Join(baseUrl, pathStr)
} }

View File

@ -2,7 +2,6 @@ package plugins
import ( import (
"context" "context"
"os"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
@ -57,6 +56,10 @@ type Manager interface {
LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error) LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error)
// IsAppInstalled returns whether an app is installed. // IsAppInstalled returns whether an app is installed.
IsAppInstalled(id string) bool 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 { type ImportDashboardInput struct {
@ -75,10 +78,9 @@ type DataRequestHandler interface {
type PluginInstaller interface { type PluginInstaller interface {
// Install finds the plugin given the provided information // Install finds the plugin given the provided information
// and installs in the provided plugins directory. // 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 removes the specified plugin from the provided plugins directory.
Uninstall(pluginID, pluginPath string) error Uninstall(ctx context.Context, pluginID, pluginPath string) error
DownloadFile(pluginID string, tmpFile *os.File, url string, checksum string) error
} }
type PluginInstallerLogger interface { type PluginInstallerLogger interface {

View File

@ -11,8 +11,8 @@ import (
) )
func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) { func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
plugin, exists := pm.plugins[pluginID] plugin := pm.GetPlugin(pluginID)
if !exists { if plugin == nil {
return nil, plugins.PluginNotFoundError{PluginID: pluginID} 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) { func (pm *PluginManager) LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error) {
plugin, exists := pm.plugins[pluginID] plugin := pm.GetPlugin(pluginID)
if !exists { if plugin == nil {
return nil, plugins.PluginNotFoundError{PluginID: pluginID} return nil, plugins.PluginNotFoundError{PluginID: pluginID}
} }

View File

@ -4,6 +4,7 @@ import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"context"
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
@ -40,8 +41,8 @@ const (
) )
var ( var (
ErrNotFoundError = errors.New("404 not found error") ErrPluginNotFound = errors.New("plugin not found")
reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/") reGitBuild = regexp.MustCompile("^[a-zA-Z0-9_.-]*/")
) )
type BadRequestError struct { type BadRequestError struct {
@ -56,6 +57,35 @@ func (e *BadRequestError) Error() string {
return e.Status 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 { func New(skipTLSVerify bool, grafanaVersion string, logger plugins.PluginInstallerLogger) *Installer {
return &Installer{ return &Installer{
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second), 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 // Install downloads the plugin code as a zip file from specified URL
// and then extracts the zip into the provided plugins directory. // 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 isInternal := false
var checksum string var checksum string
@ -140,13 +170,13 @@ func (i *Installer) Install(pluginID, version, pluginsDir, pluginZipURL, pluginR
res, _ := toPluginDTO(pluginsDir, pluginID) 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 // download dependency plugins
for _, dep := range res.Dependencies.Plugins { for _, dep := range res.Dependencies.Plugins {
i.log.Infof("Fetching %s dependencies...", res.ID) i.log.Infof("Fetching %s dependencies...", res.ID)
if err := i.Install(dep.ID, normalizeVersion(dep.Version), pluginsDir, "", pluginRepoURL); err != nil { 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) 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. // 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) pluginDir := filepath.Join(pluginPath, pluginID)
// verify it's a plugin directory // 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) i.log.Debugf("Fetching metadata for plugin \"%s\" from repo %s", pluginID, pluginRepoURL)
body, err := i.sendRequestGetBytes(pluginRepoURL, "repo", pluginID) body, err := i.sendRequestGetBytes(pluginRepoURL, "repo", pluginID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFoundError) { if errors.Is(err, ErrPluginNotFound) {
return Plugin{}, i.log.Errorf("failed to find plugin '%s' in plugin repository. Please check if plugin ID is correct", pluginID)
fmt.Errorf("failed to find plugin \"%s\" in plugin repository. Please check if plugin ID is correct", return Plugin{}, err
pluginID)
} }
return Plugin{}, errutil.Wrap("Failed to send request", 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) { func (i *Installer) handleResponse(res *http.Response) (io.ReadCloser, error) {
if res.StatusCode == 404 { if res.StatusCode == 404 {
return nil, ErrNotFoundError return nil, ErrPluginNotFound
} }
if res.StatusCode/100 != 2 && res.StatusCode/100 != 4 { if res.StatusCode/100 != 2 && res.StatusCode/100 != 4 {
@ -405,7 +434,10 @@ func selectVersion(plugin *Plugin, version string) (*Version, error) {
latestForArch := latestSupportedVersion(plugin) latestForArch := latestSupportedVersion(plugin)
if latestForArch == nil { 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 == "" { if version == "" {
@ -419,14 +451,19 @@ func selectVersion(plugin *Plugin, version string) (*Version, error) {
} }
if len(ver.Version) == 0 { if len(ver.Version) == 0 {
return nil, fmt.Errorf("could not find a version %s for %s. The latest suitable version is %s", return nil, ErrVersionNotFound{
version, plugin.ID, latestForArch.Version) PluginID: plugin.ID,
RequestedVersion: version,
RecommendedVersion: latestForArch.Version,
}
} }
if !supportsCurrentArch(&ver) { if !supportsCurrentArch(&ver) {
return nil, fmt.Errorf( return nil, ErrVersionUnsupported{
"the version you requested is not supported on your architecture and OS, latest suitable version is %s", PluginID: plugin.ID,
latestForArch.Version) RequestedVersion: version,
RecommendedVersion: latestForArch.Version,
}
} }
return &ver, nil return &ver, nil

View File

@ -12,7 +12,7 @@ type InfraLogWrapper struct {
debugMode bool debugMode bool
} }
func New(name string, debugMode bool) (l *InfraLogWrapper) { func NewInstallerLogger(name string, debugMode bool) (l *InfraLogWrapper) {
return &InfraLogWrapper{ return &InfraLogWrapper{
debugMode: debugMode, debugMode: debugMode,
l: log.New(name), l: log.New(name),

View File

@ -12,6 +12,7 @@ import (
"reflect" "reflect"
"runtime" "runtime"
"strings" "strings"
"sync"
"time" "time"
"github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/fs"
@ -20,6 +21,7 @@ import (
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin" "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/registry"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
@ -28,7 +30,12 @@ import (
) )
var ( 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 type unsignedPluginConditionFunc = func(plugin *plugins.PluginBase) bool
@ -48,6 +55,7 @@ type PluginManager struct {
BackendPluginManager backendplugin.Manager `inject:""` BackendPluginManager backendplugin.Manager `inject:""`
Cfg *setting.Cfg `inject:""` Cfg *setting.Cfg `inject:""`
SQLStore *sqlstore.SQLStore `inject:""` SQLStore *sqlstore.SQLStore `inject:""`
pluginInstaller plugins.PluginInstaller
log log.Logger log log.Logger
scanningErrors []error scanningErrors []error
@ -64,6 +72,7 @@ type PluginManager struct {
panels map[string]*plugins.PanelPlugin panels map[string]*plugins.PanelPlugin
apps map[string]*plugins.AppPlugin apps map[string]*plugins.AppPlugin
staticRoutes []*plugins.PluginStaticRoute staticRoutes []*plugins.PluginStaticRoute
pluginsMu sync.RWMutex
} }
func init() { func init() {
@ -88,6 +97,7 @@ func (pm *PluginManager) Init() error {
pm.log = log.New("plugins") pm.log = log.New("plugins")
plog = log.New("plugins") plog = log.New("plugins")
pm.pluginScanningErrors = map[string]plugins.PluginError{} pm.pluginScanningErrors = map[string]plugins.PluginError{}
pm.pluginInstaller = installer.New(false, pm.Cfg.BuildVersion, installerLog)
pm.log.Info("Starting plugin search") pm.log.Info("Starting plugin search")
@ -109,11 +119,21 @@ func (pm *PluginManager) Init() error {
} }
} }
// check if plugins dir exists err = pm.initExternalPlugins()
exists, err = fs.Exists(pm.Cfg.PluginsPath)
if err != nil { if err != nil {
return err 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 !exists {
if err = os.MkdirAll(pm.Cfg.PluginsPath, os.ModePerm); err != nil { 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) 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 return err
} }
for _, panel := range pm.panels { var staticRoutesList []*plugins.PluginStaticRoute
for _, panel := range pm.Panels() {
staticRoutes := panel.InitFrontendPlugin(pm.Cfg) 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) 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) 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) 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 { if p.IsCorePlugin {
p.Signature = plugins.PluginSignatureInternal p.Signature = plugins.PluginSignatureInternal
} else { } else {
@ -182,14 +204,23 @@ func (pm *PluginManager) Run(ctx context.Context) error {
} }
func (pm *PluginManager) Renderer() *plugins.RendererPlugin { func (pm *PluginManager) Renderer() *plugins.RendererPlugin {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return pm.renderer return pm.renderer
} }
func (pm *PluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { func (pm *PluginManager) GetDataSource(id string) *plugins.DataSourcePlugin {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return pm.dataSources[id] return pm.dataSources[id]
} }
func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin { func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
var rslt []*plugins.DataSourcePlugin var rslt []*plugins.DataSourcePlugin
for _, ds := range pm.dataSources { for _, ds := range pm.dataSources {
rslt = append(rslt, ds) rslt = append(rslt, ds)
@ -199,18 +230,30 @@ func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin {
} }
func (pm *PluginManager) DataSourceCount() int { func (pm *PluginManager) DataSourceCount() int {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return len(pm.dataSources) return len(pm.dataSources)
} }
func (pm *PluginManager) PanelCount() int { func (pm *PluginManager) PanelCount() int {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return len(pm.panels) return len(pm.panels)
} }
func (pm *PluginManager) AppCount() int { func (pm *PluginManager) AppCount() int {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return len(pm.apps) return len(pm.apps)
} }
func (pm *PluginManager) Plugins() []*plugins.PluginBase { func (pm *PluginManager) Plugins() []*plugins.PluginBase {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
var rslt []*plugins.PluginBase var rslt []*plugins.PluginBase
for _, p := range pm.plugins { for _, p := range pm.plugins {
rslt = append(rslt, p) rslt = append(rslt, p)
@ -220,6 +263,9 @@ func (pm *PluginManager) Plugins() []*plugins.PluginBase {
} }
func (pm *PluginManager) Apps() []*plugins.AppPlugin { func (pm *PluginManager) Apps() []*plugins.AppPlugin {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
var rslt []*plugins.AppPlugin var rslt []*plugins.AppPlugin
for _, p := range pm.apps { for _, p := range pm.apps {
rslt = append(rslt, p) rslt = append(rslt, p)
@ -228,11 +274,29 @@ func (pm *PluginManager) Apps() []*plugins.AppPlugin {
return rslt 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 { func (pm *PluginManager) GetPlugin(id string) *plugins.PluginBase {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return pm.plugins[id] return pm.plugins[id]
} }
func (pm *PluginManager) GetApp(id string) *plugins.AppPlugin { func (pm *PluginManager) GetApp(id string) *plugins.AppPlugin {
pm.pluginsMu.RLock()
defer pm.pluginsMu.RUnlock()
return pm.apps[id] return pm.apps[id]
} }
@ -290,6 +354,25 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error {
pm.log.Debug("Initial plugin loading done") 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{}{ pluginTypes := map[string]interface{}{
"panel": plugins.PanelPlugin{}, "panel": plugins.PanelPlugin{},
"datasource": plugins.DataSourcePlugin{}, "datasource": plugins.DataSourcePlugin{},
@ -371,7 +454,7 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error {
} }
if len(scanner.errors) > 0 { 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 pm.scanningErrors = scanner.errors
} }
@ -385,6 +468,9 @@ func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugin
return err return err
} }
pm.pluginsMu.Lock()
defer pm.pluginsMu.Unlock()
var pb *plugins.PluginBase var pb *plugins.PluginBase
switch p := plug.(type) { switch p := plug.(type) {
case *plugins.DataSourcePlugin: case *plugins.DataSourcePlugin:
@ -403,12 +489,6 @@ func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugin
panic(fmt.Sprintf("Unrecognized plugin type %T", plug)) 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) { if !strings.HasPrefix(pluginBase.PluginDir, pm.Cfg.StaticRootPath) {
pm.log.Info("Registering plugin", "id", pb.Id) 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. // GetDataPlugin gets a DataPlugin with a certain name. If none is found, nil is returned.
//nolint: staticcheck // plugins.DataPlugin deprecated //nolint: staticcheck // plugins.DataPlugin deprecated
func (pm *PluginManager) GetDataPlugin(id string) plugins.DataPlugin { 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 return p
} }
@ -683,3 +766,99 @@ func (pm *PluginManager) GetDataPlugin(id string) plugins.DataPlugin {
func (pm *PluginManager) StaticRoutes() []*plugins.PluginStaticRoute { func (pm *PluginManager) StaticRoutes() []*plugins.PluginStaticRoute {
return pm.staticRoutes 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
}
}
}

View File

@ -5,8 +5,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"reflect"
"strings"
"testing" "testing"
"github.com/google/go-cmp/cmp"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
@ -18,7 +22,33 @@ import (
) )
func TestPluginManager_Init(t *testing.T) { 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 := createManager(t, func(pm *PluginManager) {
pm.Cfg.PluginSettings = setting.PluginSettings{ pm.Cfg.PluginSettings = setting.PluginSettings{
"nginx-app": map[string]string{ "nginx-app": map[string]string{
@ -30,10 +60,10 @@ func TestPluginManager_Init(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, pm.scanningErrors) assert.Empty(t, pm.scanningErrors)
assert.Greater(t, len(pm.dataSources), 1) verifyCorePluginCatalogue(t, pm)
assert.Greater(t, len(pm.panels), 1)
assert.Equal(t, "app/plugins/datasource/graphite/module", pm.dataSources["graphite"].Module)
assert.NotEmpty(t, pm.apps) 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/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) 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() err := pm.Init()
require.NoError(t, err) 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) { 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) { 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) { pm := createManager(t, func(manager *PluginManager) {
manager.Cfg.PluginsPath = "testdata/valid-v2-signature" manager.Cfg.PluginsPath = pluginsDir
}) })
err := pm.Init() err := pm.Init()
require.NoError(t, err) require.NoError(t, err)
require.Empty(t, pm.scanningErrors) require.Empty(t, pm.scanningErrors)
const pluginID = "test" // capture manager plugin state
assert.NotNil(t, pm.plugins[pluginID]) datasources := pm.dataSources
assert.Equal(t, "datasource", pm.plugins[pluginID].Type) panels := pm.panels
assert.Equal(t, "Test", pm.plugins[pluginID].Name) apps := pm.apps
assert.Equal(t, pluginID, pm.plugins[pluginID].Id)
assert.Equal(t, "1.0.0", pm.plugins[pluginID].Info.Version) verifyPluginManagerState := func() {
assert.Equal(t, plugins.PluginSignatureValid, pm.plugins[pluginID].Signature) assert.Empty(t, pm.scanningErrors)
assert.Equal(t, plugins.GrafanaType, pm.plugins[pluginID].SignatureType) verifyCorePluginCatalogue(t, pm)
assert.Equal(t, "Grafana Labs", pm.plugins[pluginID].SignatureOrg)
assert.False(t, pm.plugins[pluginID].IsCorePlugin) // 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) { 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 { type fakeBackendPluginManager struct {
backendplugin.Manager backendplugin.Manager
@ -232,6 +489,33 @@ func (f *fakeBackendPluginManager) Register(pluginID string, factory backendplug
return nil 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 { func (f *fakeBackendPluginManager) StartPlugin(ctx context.Context, pluginID string) error {
return nil 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) { 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 { func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager {
t.Helper() t.Helper()

View File

@ -16,7 +16,7 @@ func (pm *PluginManager) GetPluginSettings(orgID int64) (map[string]*models.Plug
pluginMap[plug.PluginId] = plug pluginMap[plug.PluginId] = plug
} }
for _, pluginDef := range pm.plugins { for _, pluginDef := range pm.Plugins() {
// ignore entries that exists // ignore entries that exists
if _, ok := pluginMap[pluginDef.Id]; ok { if _, ok := pluginMap[pluginDef.Id]; ok {
continue continue
@ -63,8 +63,8 @@ func (pm *PluginManager) GetEnabledPlugins(orgID int64) (*plugins.EnabledPlugins
return enabledPlugins, err return enabledPlugins, err
} }
for pluginID, app := range pm.apps { for _, app := range pm.Apps() {
if b, ok := pluginSettingMap[pluginID]; ok { if b, ok := pluginSettingMap[app.Id]; ok {
app.Pinned = b.Pinned app.Pinned = b.Pinned
enabledPlugins.Apps = append(enabledPlugins.Apps, app) enabledPlugins.Apps = append(enabledPlugins.Apps, app)
} }

View File

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

View File

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

View File

@ -71,7 +71,7 @@ func (pm *PluginManager) checkForUpdates() {
return return
} }
for _, plug := range pm.plugins { for _, plug := range pm.Plugins() {
for _, gplug := range gNetPlugins { for _, gplug := range gNetPlugins {
if gplug.Slug == plug.Id { if gplug.Slug == plug.Id {
plug.GrafanaNetVersion = gplug.Version plug.GrafanaNetVersion = gplug.Version

View File

@ -2,6 +2,7 @@ package plugins
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
@ -13,21 +14,28 @@ const (
PluginTypeDashboard = "dashboard" 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 { type PluginNotFoundError struct {
PluginID string PluginID string
} }
func (e PluginNotFoundError) Error() 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 { type DuplicatePluginError struct {
Plugin *PluginBase PluginID string
ExistingPlugin *PluginBase ExistingPluginDir string
} }
func (e DuplicatePluginError) Error() 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 { func (e DuplicatePluginError) Is(err error) bool {

View File

@ -257,6 +257,7 @@ type Cfg struct {
PluginSettings PluginSettings PluginSettings PluginSettings
PluginsAllowUnsigned []string PluginsAllowUnsigned []string
MarketplaceURL string MarketplaceURL string
MarketplaceAppEnabled bool
DisableSanitizeHtml bool DisableSanitizeHtml bool
EnterpriseLicensePath string EnterpriseLicensePath string
@ -888,6 +889,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug) cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug)
} }
cfg.MarketplaceURL = pluginsSection.Key("marketplace_url").MustString("https://grafana.com/grafana/plugins/") 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 // Read and populate feature toggles list
featureTogglesSection := iniFile.Section("feature_toggles") featureTogglesSection := iniFile.Section("feature_toggles")

View File

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

View File

@ -231,6 +231,12 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
_, err = anonSect.NewKey("enabled", "false") _, err = anonSect.NewKey("enabled", "false")
require.NoError(t, err) 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") cfgPath := filepath.Join(cfgDir, "test.ini")
@ -244,9 +250,10 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
} }
type GrafanaOpts struct { type GrafanaOpts struct {
EnableCSP bool EnableCSP bool
EnableFeatureToggles []string EnableFeatureToggles []string
AnonymousUserRole models.RoleType AnonymousUserRole models.RoleType
EnableQuota bool EnableQuota bool
DisableAnonymous bool DisableAnonymous bool
MarketplaceAppEnabled bool
} }

View File

@ -79,7 +79,7 @@ func (s *CloudWatchService) Init() error {
QueryDataHandler: newExecutor(s.LogsService, im, s.Cfg, awsds.NewSessionCache()), 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) plog.Error("Failed to register plugin", "error", err)
} }
return nil return nil

View File

@ -1,6 +1,7 @@
package testdatasource package testdatasource
import ( import (
"context"
"net/http" "net/http"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
@ -35,7 +36,7 @@ func (p *testDataPlugin) Init() error {
CallResourceHandler: httpadapter.New(resourceMux), CallResourceHandler: httpadapter.New(resourceMux),
StreamHandler: newTestStreamHandler(p.logger), StreamHandler: newTestStreamHandler(p.logger),
}) })
err := p.BackendPluginManager.Register("testdata", factory) err := p.BackendPluginManager.RegisterAndStart(context.Background(), "testdata", factory)
if err != nil { if err != nil {
p.logger.Error("Failed to register plugin", "error", err) p.logger.Error("Failed to register plugin", "error", err)
} }