mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 02:23:28 +08: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:
@ -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()
|
||||
|
||||
|
Reference in New Issue
Block a user