Plugins: Make it possible to support multiple plugin versions (#82116)

* first pass

* use version in more places

* add comment

* update installer

* fix wire

* fix tests

* tidy

* simplify changes

* fix in mem

* remove unused step

* fix step dupe logic for child plugins + add tests
This commit is contained in:
Will Browne 2024-02-12 12:47:49 +01:00 committed by GitHub
parent 730e1d2485
commit 788b9afda3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 292 additions and 131 deletions

View File

@ -30,7 +30,7 @@ func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string,
return nil
}
func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID string) error {
func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID, _ string) error {
delete(pm.plugins, pluginID)
return nil
}

View File

@ -274,7 +274,12 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res
pluginID := web.Params(c.Req)[":pluginId"]
name := web.Params(c.Req)[":name"]
content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, name)
p, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
return response.Error(http.StatusNotFound, "Plugin not installed", nil)
}
content, err := hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, name)
if err != nil {
var notFound plugins.NotFoundError
if errors.As(err, &notFound) {
@ -286,7 +291,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res
// fallback try readme
if len(content) == 0 {
content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, "readme")
content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, p.Info.Version, "readme")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
return response.Error(http.StatusNotFound, plugins.ErrFileNotExist.Error(), nil)
@ -354,7 +359,7 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
// serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client.
func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin pluginstore.Plugin, assetPath string) {
f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, assetPath)
f, err := hs.pluginFileStore.File(c.Req.Context(), plugin.ID, plugin.Info.Version, assetPath)
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
c.JsonApiErr(404, "Plugin file not found", nil)
@ -476,8 +481,12 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons
func (hs *HTTPServer) UninstallPlugin(c *contextmodel.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
plugin, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
return response.Error(http.StatusNotFound, "Plugin not installed", nil)
}
err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID)
err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID, plugin.Info.Version)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotInstalled) {
return response.Error(http.StatusNotFound, "Plugin not installed", err)
@ -494,19 +503,19 @@ func translatePluginRequestErrorToAPIError(err error) response.Response {
return response.ErrOrFallback(http.StatusInternalServerError, "Plugin request failed", err)
}
func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID string, name string) ([]byte, error) {
func (hs *HTTPServer) pluginMarkdown(ctx context.Context, pluginID, pluginVersion, name string) ([]byte, error) {
file, err := mdFilepath(strings.ToUpper(name))
if err != nil {
return make([]byte, 0), err
}
md, err := hs.pluginFileStore.File(ctx, pluginID, file)
md, err := hs.pluginFileStore.File(ctx, pluginID, pluginVersion, file)
if err != nil {
if errors.Is(err, plugins.ErrPluginNotInstalled) {
return make([]byte, 0), plugins.NotFoundError{PluginID: pluginID}
}
md, err = hs.pluginFileStore.File(ctx, pluginID, strings.ToLower(file))
md, err = hs.pluginFileStore.File(ctx, pluginID, pluginVersion, strings.ToLower(file))
if err != nil {
return make([]byte, 0), nil
}

View File

@ -49,6 +49,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
canInstall := []ac.Permission{{Action: pluginaccesscontrol.ActionInstall}}
cannotInstall := []ac.Permission{{Action: "plugins:cannotinstall"}}
pluginID := "grafana-test-datasource"
localOrg := int64(1)
globalOrg := int64(ac.GlobalOrgID)
@ -88,11 +89,17 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
hs.pluginInstaller = NewFakePluginInstaller()
hs.pluginFileStore = &fakes.FakePluginFileStore{}
hs.pluginStore = pluginstore.NewFakePluginStore(pluginstore.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
})
})
t.Run(testName("Install", tc), func(t *testing.T) {
input := strings.NewReader(`{"version": "1.0.2"}`)
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(tc.permissionOrg, tc.permissions))
endpoint := fmt.Sprintf("/api/plugins/%s/install", pluginID)
req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
require.Equal(t, tc.expectedCode, res.StatusCode)
@ -101,7 +108,8 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
t.Run(testName("Uninstall", tc), func(t *testing.T) {
input := strings.NewReader("{ }")
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(tc.permissionOrg, tc.permissions))
endpoint := fmt.Sprintf("/api/plugins/%s/uninstall", pluginID)
req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(tc.permissionOrg, tc.permissions))
res, err := server.SendJSON(req)
require.NoError(t, err)
require.Equal(t, tc.expectedCode, res.StatusCode)
@ -401,14 +409,14 @@ func TestMakePluginResourceRequestContentTypeEmpty(t *testing.T) {
func TestPluginMarkdown(t *testing.T) {
t.Run("Plugin not installed returns error", func(t *testing.T) {
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
return nil, plugins.ErrPluginNotInstalled
},
}
hs := HTTPServer{pluginFileStore: pluginFileStore}
pluginID := "test-datasource"
md, err := hs.pluginMarkdown(context.Background(), pluginID, "test")
md, err := hs.pluginMarkdown(context.Background(), pluginID, "", "test")
require.ErrorAs(t, err, &plugins.NotFoundError{PluginID: pluginID})
require.Equal(t, []byte{}, md)
})
@ -416,7 +424,7 @@ func TestPluginMarkdown(t *testing.T) {
t.Run("File fetch will be retried using different casing if error occurs", func(t *testing.T) {
var requestedFiles []string
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
requestedFiles = append(requestedFiles, filename)
return nil, errors.New("some error")
},
@ -424,7 +432,7 @@ func TestPluginMarkdown(t *testing.T) {
hs := HTTPServer{pluginFileStore: pluginFileStore}
md, err := hs.pluginMarkdown(context.Background(), "", "reAdMe")
md, err := hs.pluginMarkdown(context.Background(), "", "", "reAdMe")
require.NoError(t, err)
require.Equal(t, []byte{}, md)
require.Equal(t, []string{"README.md", "readme.md"}, requestedFiles)
@ -453,7 +461,7 @@ func TestPluginMarkdown(t *testing.T) {
data := []byte{123}
var requestedFiles []string
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
requestedFiles = append(requestedFiles, filename)
return &plugins.File{Content: data}, nil
},
@ -461,7 +469,7 @@ func TestPluginMarkdown(t *testing.T) {
hs := HTTPServer{pluginFileStore: pluginFileStore}
md, err := hs.pluginMarkdown(context.Background(), "test-datasource", tc.filePath)
md, err := hs.pluginMarkdown(context.Background(), "test-datasource", "", tc.filePath)
require.NoError(t, err)
require.Equal(t, data, md)
require.Equal(t, tc.expected, requestedFiles)
@ -471,7 +479,7 @@ func TestPluginMarkdown(t *testing.T) {
t.Run("Non markdown file request returns an error", func(t *testing.T) {
hs := HTTPServer{pluginFileStore: &fakes.FakePluginFileStore{}}
md, err := hs.pluginMarkdown(context.Background(), "", "test.json")
md, err := hs.pluginMarkdown(context.Background(), "", "", "test.json")
require.ErrorIs(t, err, ErrUnexpectedFileExtension)
require.Equal(t, []byte{}, md)
})
@ -480,14 +488,14 @@ func TestPluginMarkdown(t *testing.T) {
data := []byte{1, 2, 3}
pluginFileStore := &fakes.FakePluginFileStore{
FileFunc: func(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
FileFunc: func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
return &plugins.File{Content: data}, nil
},
}
hs := HTTPServer{pluginFileStore: pluginFileStore}
md, err := hs.pluginMarkdown(context.Background(), "", "someFile")
md, err := hs.pluginMarkdown(context.Background(), "", "", "someFile")
require.NoError(t, err)
require.Equal(t, data, md)
})

View File

@ -14,7 +14,7 @@ type Installer interface {
// Add adds a new plugin.
Add(ctx context.Context, pluginID, version string, opts CompatOpts) error
// Remove removes an existing plugin.
Remove(ctx context.Context, pluginID string) error
Remove(ctx context.Context, pluginID, version string) error
}
type PluginSource interface {
@ -25,7 +25,7 @@ type PluginSource interface {
type FileStore interface {
// File retrieves a plugin file.
File(ctx context.Context, pluginID, filename string) (*File, error)
File(ctx context.Context, pluginID, pluginVersion, filename string) (*File, error)
}
type File struct {

View File

@ -44,7 +44,7 @@ func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest)
return nil, errNilRequest
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@ -87,7 +87,7 @@ func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceReq
return errNilSender
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return plugins.ErrPluginNotRegistered
}
@ -130,7 +130,7 @@ func (s *Service) CollectMetrics(ctx context.Context, req *backend.CollectMetric
return nil, errNilRequest
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@ -152,7 +152,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
return nil, errNilRequest
}
p, exists := s.plugin(ctx, req.PluginContext.PluginID)
p, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@ -182,7 +182,7 @@ func (s *Service) SubscribeStream(ctx context.Context, req *backend.SubscribeStr
return nil, errNilRequest
}
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID)
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@ -195,7 +195,7 @@ func (s *Service) PublishStream(ctx context.Context, req *backend.PublishStreamR
return nil, errNilRequest
}
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID)
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return nil, plugins.ErrPluginNotRegistered
}
@ -212,7 +212,7 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest,
return errNilSender
}
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID)
plugin, exists := s.plugin(ctx, req.PluginContext.PluginID, req.PluginContext.PluginVersion)
if !exists {
return plugins.ErrPluginNotRegistered
}
@ -221,8 +221,8 @@ func (s *Service) RunStream(ctx context.Context, req *backend.RunStreamRequest,
}
// plugin finds a plugin with `pluginID` from the registry that is not decommissioned
func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) {
p, exists := s.pluginRegistry.Plugin(ctx, pluginID)
func (s *Service) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) {
p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion)
if !exists {
return nil, false
}

View File

@ -176,7 +176,7 @@ func NewFakePluginRegistry() *FakePluginRegistry {
}
}
func (f *FakePluginRegistry) Plugin(_ context.Context, id string) (*plugins.Plugin, bool) {
func (f *FakePluginRegistry) Plugin(_ context.Context, id, _ string) (*plugins.Plugin, bool) {
p, exists := f.Store[id]
return p, exists
}
@ -195,7 +195,7 @@ func (f *FakePluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
return nil
}
func (f *FakePluginRegistry) Remove(_ context.Context, id string) error {
func (f *FakePluginRegistry) Remove(_ context.Context, id, _ string) error {
delete(f.Store, id)
return nil
}
@ -423,12 +423,12 @@ func (s *FakePluginSource) DefaultSignature(ctx context.Context) (plugins.Signat
}
type FakePluginFileStore struct {
FileFunc func(ctx context.Context, pluginID, filename string) (*plugins.File, error)
FileFunc func(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error)
}
func (f *FakePluginFileStore) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
func (f *FakePluginFileStore) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
if f.FileFunc != nil {
return f.FileFunc(ctx, pluginID, filename)
return f.FileFunc(ctx, pluginID, pluginVersion, filename)
}
return nil, nil
}

View File

@ -21,8 +21,8 @@ func ProvideService(pluginRegistry registry.Service) *Service {
}
}
func (s *Service) File(ctx context.Context, pluginID, filename string) (*plugins.File, error) {
if p, exists := s.pluginRegistry.Plugin(ctx, pluginID); exists {
func (s *Service) File(ctx context.Context, pluginID, pluginVersion, filename string) (*plugins.File, error) {
if p, exists := s.pluginRegistry.Plugin(ctx, pluginID, pluginVersion); exists {
f, err := p.File(filename)
if err != nil {
return nil, err

View File

@ -55,7 +55,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
}
var pluginArchive *repo.PluginArchive
if plugin, exists := m.plugin(ctx, pluginID); exists {
if plugin, exists := m.plugin(ctx, pluginID, version); exists {
if plugin.IsCorePlugin() || plugin.IsBundledPlugin() {
return plugins.ErrInstallCorePlugin
}
@ -84,7 +84,7 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
}
// remove existing installation of plugin
err = m.Remove(ctx, plugin.ID)
err = m.Remove(ctx, plugin.ID, plugin.Info.Version)
if err != nil {
return err
}
@ -139,8 +139,8 @@ func (m *PluginInstaller) Add(ctx context.Context, pluginID, version string, opt
return nil
}
func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error {
plugin, exists := m.plugin(ctx, pluginID)
func (m *PluginInstaller) Remove(ctx context.Context, pluginID, version string) error {
plugin, exists := m.plugin(ctx, pluginID, version)
if !exists {
return plugins.ErrPluginNotInstalled
}
@ -168,8 +168,8 @@ func (m *PluginInstaller) Remove(ctx context.Context, pluginID string) error {
}
// plugin finds a plugin with `pluginID` from the store
func (m *PluginInstaller) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID)
func (m *PluginInstaller) plugin(ctx context.Context, pluginID, pluginVersion string) (*plugins.Plugin, bool) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion)
if !exists {
return nil, false
}

View File

@ -23,6 +23,8 @@ func TestPluginManager_Add_Remove(t *testing.T) {
const (
pluginID, v1 = "test-panel", "1.0.0"
zipNameV1 = "test-panel-1.0.0.zip"
v2 = "2.0.0"
zipNameV2 = "test-panel-2.0.0.zip"
)
// mock a plugin to be returned automatically by the plugin loader
@ -83,10 +85,6 @@ func TestPluginManager_Add_Remove(t *testing.T) {
})
t.Run("Update plugin to different version", func(t *testing.T) {
const (
v2 = "2.0.0"
zipNameV2 = "test-panel-2.0.0.zip"
)
// mock a plugin to be returned automatically by the plugin loader
pluginV2 := createPlugin(t, pluginID, plugins.ClassExternal, true, true, func(plugin *plugins.Plugin) {
plugin.Info.Version = v2
@ -138,7 +136,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
},
}
err = inst.Remove(context.Background(), pluginID)
err = inst.Remove(context.Background(), pluginID, v2)
require.NoError(t, err)
require.Equal(t, []string{pluginID}, unloadedPlugins)
@ -146,7 +144,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
t.Run("Won't remove if not exists", func(t *testing.T) {
inst.pluginRegistry = fakes.NewFakePluginRegistry()
err = inst.Remove(context.Background(), pluginID)
err = inst.Remove(context.Background(), pluginID, v2)
require.Equal(t, plugins.ErrPluginNotInstalled, err)
})
})
@ -179,7 +177,7 @@ func TestPluginManager_Add_Remove(t *testing.T) {
require.Equal(t, plugins.ErrInstallCorePlugin, err)
t.Run(fmt.Sprintf("Can't uninstall %s plugin", tc.class), func(t *testing.T) {
err = pm.Remove(context.Background(), p.ID)
err = pm.Remove(context.Background(), p.ID, p.Info.Version)
require.Equal(t, plugins.ErrUninstallCorePlugin, err)
})
}

View File

@ -6,9 +6,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
)
// DefaultFindFunc is the default function used for the Find step of the Discovery stage. It will scan the local
@ -17,44 +15,6 @@ func DefaultFindFunc(cfg *config.Cfg) FindFunc {
return finder.NewLocalFinder(cfg.DevMode, cfg.Features).Find
}
// DuplicatePluginValidation is a filter step that will filter out any plugins that are already registered with the
// registry. This includes both the primary plugin and any child plugins, which are matched using the plugin ID field.
type DuplicatePluginValidation struct {
registry registry.Service
log log.Logger
}
// NewDuplicatePluginFilterStep returns a new DuplicatePluginValidation.
func NewDuplicatePluginFilterStep(registry registry.Service) *DuplicatePluginValidation {
return &DuplicatePluginValidation{
registry: registry,
log: log.New("plugins.dedupe"),
}
}
// Filter will filter out any plugins that are already registered with the registry.
func (d *DuplicatePluginValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
res := make([]*plugins.FoundBundle, 0, len(bundles))
for _, b := range bundles {
_, exists := d.registry.Plugin(ctx, b.Primary.JSONData.ID)
if exists {
d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID)
continue
}
for _, child := range b.Children {
_, exists = d.registry.Plugin(ctx, child.JSONData.ID)
if exists {
d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID)
continue
}
}
res = append(res, b)
}
return res, nil
}
// PermittedPluginTypesFilter is a filter step that will filter out any plugins that are not of a permitted type.
type PermittedPluginTypesFilter struct {
permittedTypes []plugins.Type

View File

@ -54,7 +54,7 @@ func newDeregister(pluginRegistry registry.Service) *Deregister {
// Deregister removes a plugin from the plugin registry.
func (d *Deregister) Deregister(ctx context.Context, p *plugins.Plugin) error {
if err := d.pluginRegistry.Remove(ctx, p.ID); err != nil {
if err := d.pluginRegistry.Remove(ctx, p.ID, p.Info.Version); err != nil {
return err
}
d.log.Debug("Plugin unregistered", "pluginId", p.ID)

View File

@ -8,12 +8,12 @@ import (
// Service is responsible for the internal storing and retrieval of plugins.
type Service interface {
// Plugin finds a plugin by its ID.
Plugin(ctx context.Context, id string) (*plugins.Plugin, bool)
// Plugin finds a plugin by its ID and version.
Plugin(ctx context.Context, id, version string) (*plugins.Plugin, bool)
// Plugins returns all plugins.
Plugins(ctx context.Context) []*plugins.Plugin
// Add adds the provided plugin to the registry.
Add(ctx context.Context, plugin *plugins.Plugin) error
// Remove deletes the requested plugin from the registry.
Remove(ctx context.Context, id string) error
Remove(ctx context.Context, id, version string) error
}

View File

@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
)
// InMemory is a registry that only allows a single version of a plugin to be registered at a time.
type InMemory struct {
store map[string]*plugins.Plugin
alias map[string]*plugins.Plugin
@ -25,7 +26,7 @@ func NewInMemory() *InMemory {
}
}
func (i *InMemory) Plugin(_ context.Context, pluginID string) (*plugins.Plugin, bool) {
func (i *InMemory) Plugin(_ context.Context, pluginID, _ string) (*plugins.Plugin, bool) {
return i.plugin(pluginID)
}
@ -56,7 +57,7 @@ func (i *InMemory) Add(_ context.Context, p *plugins.Plugin) error {
return nil
}
func (i *InMemory) Remove(_ context.Context, pluginID string) error {
func (i *InMemory) Remove(_ context.Context, pluginID, _ string) error {
p, ok := i.plugin(pluginID)
if !ok {
return fmt.Errorf("plugin %s is not registered", pluginID)

View File

@ -11,29 +11,41 @@ import (
"github.com/grafana/grafana/pkg/plugins"
)
const pluginID = "test-ds"
const (
pluginID = "test-ds"
v1 = "1.0.0"
v2 = "2.0.0"
)
func TestInMemory(t *testing.T) {
t.Run("Test mix of registry operations", func(t *testing.T) {
i := NewInMemory()
ctx := context.Background()
p, exists := i.Plugin(ctx, pluginID)
p, exists := i.Plugin(ctx, pluginID, v1)
require.False(t, exists)
require.Nil(t, p)
err := i.Remove(ctx, pluginID)
err := i.Remove(ctx, pluginID, v1)
require.EqualError(t, err, fmt.Errorf("plugin %s is not registered", pluginID).Error())
pv1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v1}}}
err = i.Add(ctx, pv1)
require.NoError(t, err)
pv2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID, Info: plugins.Info{Version: v2}}}
err = i.Add(ctx, pv2)
require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID))
existingP, exists := i.Plugin(ctx, pluginID, v1)
require.True(t, exists)
require.Equal(t, pv1, existingP)
p = &plugins.Plugin{JSONData: plugins.JSONData{ID: pluginID}}
err = i.Add(ctx, p)
require.NoError(t, err)
require.Errorf(t, err, fmt.Sprintf("plugin %s is already registered", pluginID))
existingP, exists := i.Plugin(ctx, pluginID)
require.True(t, exists)
require.Equal(t, p, existingP)
err = i.Remove(ctx, pluginID)
err = i.Remove(ctx, pluginID, v1)
require.NoError(t, err)
existingPlugins := i.Plugins(ctx)
@ -87,6 +99,28 @@ func TestInMemory_Add(t *testing.T) {
},
err: fmt.Errorf("plugin %s is already registered", pluginID),
},
{
name: "Cannot add a plugin to the registry even if it has a different version",
mocks: mocks{
store: map[string]*plugins.Plugin{
pluginID: {
JSONData: plugins.JSONData{
ID: pluginID,
Info: plugins.Info{Version: v1},
},
},
},
},
args: args{
p: &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
Info: plugins.Info{Version: v2},
},
},
},
err: fmt.Errorf("plugin %s is already registered", pluginID),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -151,7 +185,7 @@ func TestInMemory_Plugin(t *testing.T) {
i := &InMemory{
store: tt.mocks.store,
}
p, exists := i.Plugin(context.Background(), tt.args.pluginID)
p, exists := i.Plugin(context.Background(), tt.args.pluginID, "")
if exists != tt.exists {
t.Errorf("Plugin() got1 = %v, expected %v", exists, tt.exists)
}
@ -265,7 +299,7 @@ func TestInMemory_Remove(t *testing.T) {
i := &InMemory{
store: tt.mocks.store,
}
err := i.Remove(context.Background(), tt.args.pluginID)
err := i.Remove(context.Background(), tt.args.pluginID, "")
require.Equal(t, tt.err, err)
})
}
@ -280,7 +314,7 @@ func TestAliasSupport(t *testing.T) {
pluginIdOld := "plugin-old"
pluginIdOld2 := "plugin-old2"
p, exists := i.Plugin(ctx, pluginIdNew)
p, exists := i.Plugin(ctx, pluginIdNew, "")
require.False(t, exists)
require.Nil(t, p)
@ -294,17 +328,17 @@ func TestAliasSupport(t *testing.T) {
require.NoError(t, err)
// Can lookup by the new ID
found, exists := i.Plugin(ctx, pluginIdNew)
found, exists := i.Plugin(ctx, pluginIdNew, "")
require.True(t, exists)
require.Equal(t, pluginNew, found)
// Can lookup by the old ID
found, exists = i.Plugin(ctx, pluginIdOld)
found, exists = i.Plugin(ctx, pluginIdOld, "")
require.True(t, exists)
require.Equal(t, pluginNew, found)
// Can lookup by the other old ID
found, exists = i.Plugin(ctx, pluginIdOld2)
found, exists = i.Plugin(ctx, pluginIdOld2, "")
require.True(t, exists)
require.Equal(t, pluginNew, found)
@ -313,7 +347,7 @@ func TestAliasSupport(t *testing.T) {
ID: pluginIdOld,
}}
require.NoError(t, i.Add(ctx, pluginOld))
found, exists = i.Plugin(ctx, pluginIdOld)
found, exists = i.Plugin(ctx, pluginIdOld, "")
require.True(t, exists)
require.Equal(t, pluginOld, found)
})

View File

@ -89,8 +89,8 @@ func NewMetricsMiddleware(promRegisterer prometheus.Registerer, pluginRegistry r
}
// pluginTarget returns the value for the "target" Prometheus label for the given plugin ID.
func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) (string, error) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID)
func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID, pluginVersion string) (string, error) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID, pluginVersion)
if !exists {
return "", plugins.ErrPluginNotRegistered
}
@ -99,7 +99,7 @@ func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) (
// instrumentPluginRequestSize tracks the size of the given request in the m.pluginRequestSize metric.
func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, requestSize float64) error {
target, err := m.pluginTarget(ctx, pluginCtx.PluginID)
target, err := m.pluginTarget(ctx, pluginCtx.PluginID, pluginCtx.PluginVersion)
if err != nil {
return err
}
@ -109,7 +109,7 @@ func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, plu
// instrumentPluginRequest increments the m.pluginRequestCounter metric and tracks the duration of the given request.
func (m *MetricsMiddleware) instrumentPluginRequest(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, fn func(context.Context) (requestStatus, error)) error {
target, err := m.pluginTarget(ctx, pluginCtx.PluginID)
target, err := m.pluginTarget(ctx, pluginCtx.PluginID, pluginCtx.PluginVersion)
if err != nil {
return err
}

View File

@ -24,8 +24,8 @@ func ProvideFileStoreManager(pluginStore pluginstore.Store, pluginFileStore plug
}
}
var openDashboardFile = func(ctx context.Context, pluginFileStore plugins.FileStore, pluginID, name string) (*plugins.File, error) {
f, err := pluginFileStore.File(ctx, pluginID, name)
var openDashboardFile = func(ctx context.Context, pluginFileStore plugins.FileStore, pluginID, pluginVersion, name string) (*plugins.File, error) {
f, err := pluginFileStore.File(ctx, pluginID, pluginVersion, name)
if err != nil {
return &plugins.File{}, err
}
@ -93,7 +93,7 @@ func (m *FileStoreManager) GetPluginDashboardFileContents(ctx context.Context, a
return nil, err
}
file, err := openDashboardFile(ctx, m.pluginFileStore, plugin.ID, cleanPath)
file, err := openDashboardFile(ctx, m.pluginFileStore, plugin.ID, plugin.Info.Version, cleanPath)
if err != nil {
return nil, err
}

View File

@ -132,7 +132,7 @@ func TestDashboardFileStore(t *testing.T) {
Data: []byte("dash2"),
},
}
openDashboardFile = func(ctx context.Context, pluginFiles plugins.FileStore, pluginID, name string) (*plugins.File, error) {
openDashboardFile = func(ctx context.Context, pluginFiles plugins.FileStore, pluginID, _, name string) (*plugins.File, error) {
f, err := mapFs.Open(name)
require.NoError(t, err)

View File

@ -1666,7 +1666,7 @@ func verifyState(t *testing.T, ps []*plugins.Plugin, reg registry.Service,
t.Helper()
for _, p := range ps {
regP, exists := reg.Plugin(context.Background(), p.ID)
regP, exists := reg.Plugin(context.Background(), p.ID, p.Info.Version)
require.True(t, exists)
if !cmp.Equal(p, regP, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(p, regP, compareOpts...))

View File

@ -29,7 +29,7 @@ func ProvideDiscoveryStage(cfg *config.Cfg, pf finder.Finder, pr registry.Servic
plugins.TypeDataSource, plugins.TypeApp, plugins.TypePanel, plugins.TypeSecretsManager,
}),
func(ctx context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
return discovery.NewDuplicatePluginFilterStep(pr).Filter(ctx, b)
return NewDuplicatePluginIDFilterStep(pr).Filter(ctx, b)
},
func(_ context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
return NewDisablePluginsStep(cfg).Filter(b)

View File

@ -3,6 +3,7 @@ package pipeline
import (
"context"
"errors"
"slices"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/plugins"
@ -11,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/manager/pipeline/initialization"
"github.com/grafana/grafana/pkg/plugins/manager/pipeline/validation"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/plugindef"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -195,3 +197,54 @@ func (c *AsExternal) Filter(cl plugins.Class, bundles []*plugins.FoundBundle) ([
}
return bundles, nil
}
// DuplicatePluginIDValidation is a filter step that will filter out any plugins that are already registered with the same
// plugin ID. This includes both the primary plugin and child plugins, which are matched using the plugin.json plugin
// ID field.
type DuplicatePluginIDValidation struct {
registry registry.Service
log log.Logger
}
// NewDuplicatePluginIDFilterStep returns a new DuplicatePluginIDValidation.
func NewDuplicatePluginIDFilterStep(registry registry.Service) *DuplicatePluginIDValidation {
return &DuplicatePluginIDValidation{
registry: registry,
log: log.New("plugins.dedupe"),
}
}
// Filter will filter out any plugins that have already been registered under the same plugin ID.
func (d *DuplicatePluginIDValidation) Filter(ctx context.Context, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
res := make([]*plugins.FoundBundle, 0, len(bundles))
var matchesPluginIDFunc = func(fp plugins.FoundPlugin) func(p *plugins.Plugin) bool {
return func(p *plugins.Plugin) bool {
return p.ID == fp.JSONData.ID
}
}
for _, b := range bundles {
ps := d.registry.Plugins(ctx)
if slices.ContainsFunc(ps, matchesPluginIDFunc(b.Primary)) {
d.log.Warn("Skipping loading of plugin as it's a duplicate", "pluginId", b.Primary.JSONData.ID)
continue
}
var nonDupeChildren []*plugins.FoundPlugin
for _, child := range b.Children {
if slices.ContainsFunc(ps, matchesPluginIDFunc(*child)) {
d.log.Warn("Skipping loading of child plugin as it's a duplicate", "pluginId", child.JSONData.ID)
continue
}
nonDupeChildren = append(nonDupeChildren, child)
}
res = append(res, &plugins.FoundBundle{
Primary: b.Primary,
Children: nonDupeChildren,
})
}
return res, nil
}

View File

@ -1,12 +1,14 @@
package pipeline
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
@ -82,3 +84,98 @@ func TestAsExternal(t *testing.T) {
require.Equal(t, filtered[0].Primary.JSONData.ID, "plugin2")
})
}
func TestDuplicatePluginIDValidation(t *testing.T) {
tcs := []struct {
name string
registeredPlugins []string
in []*plugins.FoundBundle
out []*plugins.FoundBundle
}{
{
name: "should filter out a plugin if it already exists in the plugin registry",
registeredPlugins: []string{"foobar-datasource"},
in: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "foobar-datasource",
},
},
},
},
out: []*plugins.FoundBundle{},
},
{
name: "should not filter out a plugin if it doesn't exist in the plugin registry",
registeredPlugins: []string{"foobar-datasource"},
in: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-datasource",
},
},
},
},
out: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-datasource",
},
},
},
},
},
{
name: "should filter out child plugins if they are already registered",
registeredPlugins: []string{"foobar-datasource"},
in: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-datasource",
},
},
Children: []*plugins.FoundPlugin{
{
JSONData: plugins.JSONData{
ID: "foobar-datasource",
},
},
},
},
},
out: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-datasource",
},
},
},
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
r := registry.NewInMemory()
s := NewDuplicatePluginIDFilterStep(r)
ctx := context.Background()
for _, pluginID := range tc.registeredPlugins {
err := r.Add(ctx, &plugins.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
})
require.NoError(t, err)
}
res, err := s.Filter(ctx, tc.in)
require.NoError(t, err)
require.Equal(t, tc.out, res)
})
}
}

View File

@ -17,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/searchV2"
@ -100,7 +99,7 @@ func TestIntegrationPluginManager(t *testing.T) {
ctx := context.Background()
verifyCorePluginCatalogue(t, ctx, testCtx.PluginStore)
verifyBundledPlugins(t, ctx, testCtx.PluginStore)
verifyPluginStaticRoutes(t, ctx, testCtx.PluginStore, testCtx.PluginRegistry)
verifyPluginStaticRoutes(t, ctx, testCtx.PluginStore, testCtx.PluginStore)
verifyBackendProcesses(t, testCtx.PluginRegistry.Plugins(ctx))
verifyPluginQuery(t, ctx, testCtx.PluginClient)
}
@ -253,7 +252,7 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *pluginstore.Ser
}
}
func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, reg registry.Service) {
func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.StaticRouteResolver, ps *pluginstore.Service) {
routes := make(map[string]*plugins.StaticRoute)
for _, route := range rr.Routes(ctx) {
routes[route.PluginID] = route
@ -261,13 +260,13 @@ func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.Stat
require.Len(t, routes, 2)
inputPlugin, _ := reg.Plugin(ctx, "input")
inputPlugin, _ := ps.Plugin(ctx, "input")
require.NotNil(t, routes["input"])
require.Equal(t, routes["input"].Directory, inputPlugin.FS.Base())
require.Equal(t, routes["input"].Directory, inputPlugin.Base())
testAppPlugin, _ := reg.Plugin(ctx, "test-app")
testAppPlugin, _ := ps.Plugin(ctx, "test-app")
require.Contains(t, routes, "test-app")
require.Equal(t, routes["test-app"].Directory, testAppPlugin.FS.Base())
require.Equal(t, routes["test-app"].Directory, testAppPlugin.Base())
}
func verifyBackendProcesses(t *testing.T, ps []*plugins.Plugin) {

View File

@ -18,6 +18,7 @@ var _ Store = (*Service)(nil)
// Store is the publicly accessible storage for plugins.
type Store interface {
// Plugin finds a plugin by its ID.
// Note: version is not required since Grafana only supports single versions of a plugin.
Plugin(ctx context.Context, pluginID string) (Plugin, bool)
// Plugins returns plugins by their requested type.
Plugins(ctx context.Context, pluginTypes ...plugins.Type) []Plugin
@ -104,7 +105,7 @@ func (s *Service) SecretsManager(ctx context.Context) *plugins.Plugin {
// plugin finds a plugin with `pluginID` from the registry that is not decommissioned
func (s *Service) plugin(ctx context.Context, pluginID string) (*plugins.Plugin, bool) {
p, exists := s.pluginRegistry.Plugin(ctx, pluginID)
p, exists := s.pluginRegistry.Plugin(ctx, pluginID, "") // version is not required since Grafana only supports single versions of a plugin
if !exists {
return nil, false
}

View File

@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
"github.com/grafana/grafana/pkg/services/rendering"
)
@ -106,7 +107,7 @@ func createLoader(cfg *config.Cfg, pr registry.Service, l plugins.Licensing) (lo
FindFilterFuncs: []discovery.FindFilterFunc{
discovery.NewPermittedPluginTypesFilterStep([]plugins.Type{plugins.TypeRenderer}),
func(ctx context.Context, class plugins.Class, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
return discovery.NewDuplicatePluginFilterStep(pr).Filter(ctx, bundles)
return pipeline.NewDuplicatePluginIDFilterStep(pr).Filter(ctx, bundles)
},
},
})