mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
1fbadab600
commit
c39d6ad97d
@ -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.
|
||||
|
22
docs/sources/plugins/marketplace.md
Normal file
22
docs/sources/plugins/marketplace.md
Normal 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 ...
|
@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
// check if plugins dir exists
|
||||
exists, err = fs.Exists(pm.Cfg.PluginsPath)
|
||||
err = pm.initExternalPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PluginManager) initExternalPlugins() error {
|
||||
// check if plugins dir exists
|
||||
exists, err := fs.Exists(pm.Cfg.PluginsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if err = os.MkdirAll(pm.Cfg.PluginsPath, os.ModePerm); err != nil {
|
||||
pm.log.Error("failed to create external plugins directory", "dir", pm.Cfg.PluginsPath, "error", err)
|
||||
@ -132,27 +152,29 @@ func (pm *PluginManager) Init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, panel := range pm.panels {
|
||||
var staticRoutesList []*plugins.PluginStaticRoute
|
||||
for _, panel := range pm.Panels() {
|
||||
staticRoutes := panel.InitFrontendPlugin(pm.Cfg)
|
||||
pm.staticRoutes = append(pm.staticRoutes, staticRoutes...)
|
||||
staticRoutesList = append(staticRoutesList, staticRoutes...)
|
||||
}
|
||||
|
||||
for _, ds := range pm.dataSources {
|
||||
for _, ds := range pm.DataSources() {
|
||||
staticRoutes := ds.InitFrontendPlugin(pm.Cfg)
|
||||
pm.staticRoutes = append(pm.staticRoutes, staticRoutes...)
|
||||
staticRoutesList = append(staticRoutesList, staticRoutes...)
|
||||
}
|
||||
|
||||
for _, app := range pm.apps {
|
||||
for _, app := range pm.Apps() {
|
||||
staticRoutes := app.InitApp(pm.panels, pm.dataSources, pm.Cfg)
|
||||
pm.staticRoutes = append(pm.staticRoutes, staticRoutes...)
|
||||
staticRoutesList = append(staticRoutesList, staticRoutes...)
|
||||
}
|
||||
|
||||
if pm.renderer != nil {
|
||||
if pm.Renderer() != nil {
|
||||
staticRoutes := pm.renderer.InitFrontendPlugin(pm.Cfg)
|
||||
pm.staticRoutes = append(pm.staticRoutes, staticRoutes...)
|
||||
staticRoutesList = append(staticRoutesList, staticRoutes...)
|
||||
}
|
||||
pm.staticRoutes = staticRoutesList
|
||||
|
||||
for _, p := range pm.plugins {
|
||||
for _, p := range pm.Plugins() {
|
||||
if p.IsCorePlugin {
|
||||
p.Signature = plugins.PluginSignatureInternal
|
||||
} else {
|
||||
@ -182,14 +204,23 @@ func (pm *PluginManager) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Renderer() *plugins.RendererPlugin {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return pm.renderer
|
||||
}
|
||||
|
||||
func (pm *PluginManager) GetDataSource(id string) *plugins.DataSourcePlugin {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return pm.dataSources[id]
|
||||
}
|
||||
|
||||
func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
var rslt []*plugins.DataSourcePlugin
|
||||
for _, ds := range pm.dataSources {
|
||||
rslt = append(rslt, ds)
|
||||
@ -199,18 +230,30 @@ func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin {
|
||||
}
|
||||
|
||||
func (pm *PluginManager) DataSourceCount() int {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return len(pm.dataSources)
|
||||
}
|
||||
|
||||
func (pm *PluginManager) PanelCount() int {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return len(pm.panels)
|
||||
}
|
||||
|
||||
func (pm *PluginManager) AppCount() int {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return len(pm.apps)
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Plugins() []*plugins.PluginBase {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
var rslt []*plugins.PluginBase
|
||||
for _, p := range pm.plugins {
|
||||
rslt = append(rslt, p)
|
||||
@ -220,6 +263,9 @@ func (pm *PluginManager) Plugins() []*plugins.PluginBase {
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Apps() []*plugins.AppPlugin {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
var rslt []*plugins.AppPlugin
|
||||
for _, p := range pm.apps {
|
||||
rslt = append(rslt, p)
|
||||
@ -228,11 +274,29 @@ func (pm *PluginManager) Apps() []*plugins.AppPlugin {
|
||||
return rslt
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Panels() []*plugins.PanelPlugin {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
var rslt []*plugins.PanelPlugin
|
||||
for _, p := range pm.panels {
|
||||
rslt = append(rslt, p)
|
||||
}
|
||||
|
||||
return rslt
|
||||
}
|
||||
|
||||
func (pm *PluginManager) GetPlugin(id string) *plugins.PluginBase {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return pm.plugins[id]
|
||||
}
|
||||
|
||||
func (pm *PluginManager) GetApp(id string) *plugins.AppPlugin {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
return pm.apps[id]
|
||||
}
|
||||
|
||||
@ -290,6 +354,25 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error {
|
||||
|
||||
pm.log.Debug("Initial plugin loading done")
|
||||
|
||||
pluginsByID := make(map[string]struct{})
|
||||
for scannedPluginPath, scannedPlugin := range scanner.plugins {
|
||||
// Check if scanning found duplicate plugins
|
||||
if _, dupe := pluginsByID[scannedPlugin.Id]; dupe {
|
||||
pm.log.Warn("Skipping plugin as it's a duplicate", "id", scannedPlugin.Id)
|
||||
scanner.errors = append(scanner.errors,
|
||||
plugins.DuplicatePluginError{PluginID: scannedPlugin.Id, ExistingPluginDir: scannedPlugin.PluginDir})
|
||||
delete(scanner.plugins, scannedPluginPath)
|
||||
continue
|
||||
}
|
||||
pluginsByID[scannedPlugin.Id] = struct{}{}
|
||||
|
||||
// Check if scanning found plugins that are already installed
|
||||
if existing := pm.GetPlugin(scannedPlugin.Id); existing != nil {
|
||||
pm.log.Debug("Skipping plugin as it's already installed", "plugin", existing.Id, "version", existing.Info.Version)
|
||||
delete(scanner.plugins, scannedPluginPath)
|
||||
}
|
||||
}
|
||||
|
||||
pluginTypes := map[string]interface{}{
|
||||
"panel": plugins.PanelPlugin{},
|
||||
"datasource": plugins.DataSourcePlugin{},
|
||||
@ -371,7 +454,7 @@ func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error {
|
||||
}
|
||||
|
||||
if len(scanner.errors) > 0 {
|
||||
pm.log.Warn("Some plugins failed to load", "errors", scanner.errors)
|
||||
pm.log.Warn("Some plugin scanning errors were found", "errors", scanner.errors)
|
||||
pm.scanningErrors = scanner.errors
|
||||
}
|
||||
|
||||
@ -385,6 +468,9 @@ func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugin
|
||||
return err
|
||||
}
|
||||
|
||||
pm.pluginsMu.Lock()
|
||||
defer pm.pluginsMu.Unlock()
|
||||
|
||||
var pb *plugins.PluginBase
|
||||
switch p := plug.(type) {
|
||||
case *plugins.DataSourcePlugin:
|
||||
@ -403,12 +489,6 @@ func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugin
|
||||
panic(fmt.Sprintf("Unrecognized plugin type %T", plug))
|
||||
}
|
||||
|
||||
if p, exists := pm.plugins[pb.Id]; exists {
|
||||
pm.log.Warn("Plugin is duplicate", "id", pb.Id)
|
||||
scanner.errors = append(scanner.errors, plugins.DuplicatePluginError{Plugin: pb, ExistingPlugin: p})
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(pluginBase.PluginDir, pm.Cfg.StaticRootPath) {
|
||||
pm.log.Info("Registering plugin", "id", pb.Id)
|
||||
}
|
||||
@ -666,7 +746,10 @@ func collectPluginFilesWithin(rootDir string) ([]string, error) {
|
||||
// GetDataPlugin gets a DataPlugin with a certain name. If none is found, nil is returned.
|
||||
//nolint: staticcheck // plugins.DataPlugin deprecated
|
||||
func (pm *PluginManager) GetDataPlugin(id string) plugins.DataPlugin {
|
||||
if p, exists := pm.dataSources[id]; exists && p.CanHandleDataQueries() {
|
||||
pm.pluginsMu.RLock()
|
||||
defer pm.pluginsMu.RUnlock()
|
||||
|
||||
if p := pm.GetDataSource(id); p != nil && p.CanHandleDataQueries() {
|
||||
return p
|
||||
}
|
||||
|
||||
@ -683,3 +766,99 @@ func (pm *PluginManager) GetDataPlugin(id string) plugins.DataPlugin {
|
||||
func (pm *PluginManager) StaticRoutes() []*plugins.PluginStaticRoute {
|
||||
return pm.staticRoutes
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Install(ctx context.Context, pluginID, version string) error {
|
||||
plugin := pm.GetPlugin(pluginID)
|
||||
if plugin != nil {
|
||||
if plugin.IsCorePlugin {
|
||||
return plugins.ErrInstallCorePlugin
|
||||
}
|
||||
|
||||
if plugin.Info.Version == version {
|
||||
return plugins.DuplicatePluginError{
|
||||
PluginID: pluginID,
|
||||
ExistingPluginDir: plugin.PluginDir,
|
||||
}
|
||||
}
|
||||
|
||||
// remove existing installation of plugin
|
||||
err := pm.Uninstall(context.Background(), plugin.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := pm.pluginInstaller.Install(ctx, pluginID, version, pm.Cfg.PluginsPath, "", grafanaComURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = pm.initExternalPlugins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Uninstall(ctx context.Context, pluginID string) error {
|
||||
plugin := pm.GetPlugin(pluginID)
|
||||
if plugin == nil {
|
||||
return plugins.ErrPluginNotInstalled
|
||||
}
|
||||
|
||||
if plugin.IsCorePlugin {
|
||||
return plugins.ErrUninstallCorePlugin
|
||||
}
|
||||
|
||||
// extra security check to ensure we only remove plugins that are located in the configured plugins directory
|
||||
path, err := filepath.Rel(pm.Cfg.PluginsPath, plugin.PluginDir)
|
||||
if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) {
|
||||
return plugins.ErrUninstallOutsideOfPluginDir
|
||||
}
|
||||
|
||||
if pm.BackendPluginManager.IsRegistered(pluginID) {
|
||||
err := pm.BackendPluginManager.UnregisterAndStop(ctx, pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = pm.unregister(plugin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return pm.pluginInstaller.Uninstall(ctx, pluginID, pm.Cfg.PluginsPath)
|
||||
}
|
||||
|
||||
func (pm *PluginManager) unregister(plugin *plugins.PluginBase) error {
|
||||
pm.pluginsMu.Lock()
|
||||
defer pm.pluginsMu.Unlock()
|
||||
|
||||
switch plugin.Type {
|
||||
case "panel":
|
||||
delete(pm.panels, plugin.Id)
|
||||
case "datasource":
|
||||
delete(pm.dataSources, plugin.Id)
|
||||
case "app":
|
||||
delete(pm.apps, plugin.Id)
|
||||
case "renderer":
|
||||
pm.renderer = nil
|
||||
}
|
||||
|
||||
delete(pm.plugins, plugin.Id)
|
||||
|
||||
pm.removeStaticRoute(plugin.Id)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PluginManager) removeStaticRoute(pluginID string) {
|
||||
for i, route := range pm.staticRoutes {
|
||||
if pluginID == route.PluginId {
|
||||
pm.staticRoutes = append(pm.staticRoutes[:i], pm.staticRoutes[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
27
pkg/plugins/manager/testdata/installer/plugin/MANIFEST.txt
vendored
Normal file
27
pkg/plugins/manager/testdata/installer/plugin/MANIFEST.txt
vendored
Normal 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-----
|
16
pkg/plugins/manager/testdata/installer/plugin/plugin.json
vendored
Normal file
16
pkg/plugins/manager/testdata/installer/plugin/plugin.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
89
pkg/tests/api/plugins/api_install_test.go
Normal file
89
pkg/tests/api/plugins/api_install_test.go
Normal 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)
|
||||
}
|
@ -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…
Reference in New Issue
Block a user