Plugins: Add Plugin FS abstraction (#63734)

* unexport pluginDir from dto

* first pass

* tidy

* naming + add mutex

* add dupe checking

* fix func typo

* interface + move logic from renderer

* remote finder

* remote signing

* fix tests

* tidy up

* tidy markdown logic

* split changes

* fix tests

* slim interface down

* fix status code

* tidy exec path func

* fixup

* undo changes

* remove unused func

* remove unused func

* fix goimports

* fetch remotely

* simultaneous support

* fix linter

* use var

* add exception for gosec warning

* fixup

* fix tests

* tidy

* rework cfg pattern

* simplify

* PR feedback

* fix dupe field

* remove g304 nolint

* apply PR feedback

* remove unnecessary gosec nolint

* fix finder loop and update comment

* fix map alloc

* fix test

* remove commented code
This commit is contained in:
Will Browne 2023-03-07 15:47:02 +00:00 committed by GitHub
parent 380138f57b
commit 68df83c86d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1344 additions and 870 deletions

View File

@ -271,17 +271,20 @@ func (hs *HTTPServer) GetPluginMarkdown(c *contextmodel.ReqContext) response.Res
if err != nil { if err != nil {
var notFound plugins.NotFoundError var notFound plugins.NotFoundError
if errors.As(err, &notFound) { if errors.As(err, &notFound) {
return response.Error(404, notFound.Error(), nil) return response.Error(http.StatusNotFound, notFound.Error(), nil)
} }
return response.Error(500, "Could not get markdown file", err) return response.Error(http.StatusInternalServerError, "Could not get markdown file", err)
} }
// fallback try readme // fallback try readme
if len(content) == 0 { if len(content) == 0 {
content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, "readme") content, err = hs.pluginMarkdown(c.Req.Context(), pluginID, "readme")
if err != nil { if err != nil {
return response.Error(501, "Could not get markdown file", err) if errors.Is(err, plugins.ErrFileNotExist) {
return response.Error(http.StatusNotFound, plugins.ErrFileNotExist.Error(), nil)
}
return response.Error(http.StatusNotImplemented, "Could not get markdown file", err)
} }
} }

View File

@ -20,7 +20,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/log/logtest"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/pluginscdn"
@ -270,14 +269,9 @@ func Test_GetPluginAssets(t *testing.T) {
requestedFile := filepath.Clean(tmpFile.Name()) requestedFile := filepath.Clean(tmpFile.Name())
t.Run("Given a request for an existing plugin file", func(t *testing.T) { t.Run("Given a request for an existing plugin file", func(t *testing.T) {
p := &plugins.Plugin{ p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(map[string]struct{}{requestedFile: {}}, filepath.Dir(requestedFile)))
JSONData: plugins.JSONData{
ID: pluginID,
},
PluginDir: pluginDir,
}
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p.ToDTO()}, PluginList: []plugins.PluginDTO{p},
} }
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
@ -291,7 +285,7 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
t.Run("Given a request for a relative path", func(t *testing.T) { t.Run("Given a request for a relative path", func(t *testing.T) {
p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, pluginDir) p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(map[string]struct{}{}, ""))
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p}, PluginList: []plugins.PluginDTO{p},
} }
@ -305,8 +299,26 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
}) })
t.Run("Given a request for an existing plugin file that is not listed as a signature covered file", func(t *testing.T) {
p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.Core, plugins.NewLocalFS(map[string]struct{}{
requestedFile: {},
}, ""))
service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p},
}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code)
assert.Equal(t, expectedBody, sc.resp.Body.String())
})
})
t.Run("Given a request for an non-existing plugin file", func(t *testing.T) { t.Run("Given a request for an non-existing plugin file", func(t *testing.T) {
p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, pluginDir) p := createPluginDTO(plugins.JSONData{ID: pluginID}, plugins.External, plugins.NewLocalFS(map[string]struct{}{}, ""))
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{p}, PluginList: []plugins.PluginDTO{p},
} }
@ -329,7 +341,6 @@ func Test_GetPluginAssets(t *testing.T) {
service := &plugins.FakePluginStore{ service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{}, PluginList: []plugins.PluginDTO{},
} }
l := &logtest.Fake{}
requestedFile := "nonExistent" requestedFile := "nonExistent"
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile) url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
@ -342,29 +353,6 @@ func Test_GetPluginAssets(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 404, sc.resp.Code) require.Equal(t, 404, sc.resp.Code)
require.Equal(t, "Plugin not found", respJson["message"]) require.Equal(t, "Plugin not found", respJson["message"])
require.Zero(t, l.WarnLogs.Calls)
})
})
t.Run("Given a request for a core plugin's file", func(t *testing.T) {
service := &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{
{
JSONData: plugins.JSONData{ID: pluginID},
Class: plugins.Core,
},
},
}
l := &logtest.Fake{}
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
setting.NewCfg(), service, func(sc *scenarioContext) {
callGetPluginAsset(sc)
require.Equal(t, 200, sc.resp.Code)
require.Equal(t, expectedBody, sc.resp.Body.String())
require.Zero(t, l.WarnLogs.Calls)
}) })
}) })
} }
@ -546,40 +534,19 @@ func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryData
} }
func Test_PluginsList_AccessControl(t *testing.T) { func Test_PluginsList_AccessControl(t *testing.T) {
p1 := &plugins.Plugin{ p1 := createPluginDTO(plugins.JSONData{
PluginDir: "/grafana/plugins/test-app/dist", ID: "test-app", Type: "app", Name: "test-app",
Class: plugins.External,
DefaultNavURL: "/plugins/test-app/page/test",
Signature: plugins.SignatureUnsigned,
Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app",
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.App,
Name: "test-app",
Info: plugins.Info{ Info: plugins.Info{
Version: "1.0.0", Version: "1.0.0",
}, }}, plugins.External, plugins.NewLocalFS(map[string]struct{}{}, ""))
}, p2 := createPluginDTO(
} plugins.JSONData{ID: "mysql", Type: "datasource", Name: "MySQL",
p2 := &plugins.Plugin{
PluginDir: "/grafana/public/app/plugins/datasource/mysql",
Class: plugins.Core,
Pinned: false,
Signature: plugins.SignatureInternal,
Module: "app/plugins/datasource/mysql/module",
BaseURL: "public/app/plugins/datasource/mysql",
JSONData: plugins.JSONData{
ID: "mysql",
Type: plugins.DataSource,
Name: "MySQL",
Info: plugins.Info{ Info: plugins.Info{
Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"}, Author: plugins.InfoLink{Name: "Grafana Labs", URL: "https://grafana.com"},
Description: "Data source for MySQL databases", Description: "Data source for MySQL databases",
}, }}, plugins.Core, plugins.NewLocalFS(map[string]struct{}{}, ""))
},
} pluginStore := plugins.FakePluginStore{PluginList: []plugins.PluginDTO{p1, p2}}
pluginStore := plugins.FakePluginStore{PluginList: []plugins.PluginDTO{p1.ToDTO(), p2.ToDTO()}}
pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{ pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{
"test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true}, "test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true},
@ -630,11 +597,12 @@ func Test_PluginsList_AccessControl(t *testing.T) {
} }
} }
func createPluginDTO(jd plugins.JSONData, class plugins.Class, pluginDir string) plugins.PluginDTO { func createPluginDTO(jd plugins.JSONData, class plugins.Class, files plugins.FS) plugins.PluginDTO {
p := &plugins.Plugin{ p := &plugins.Plugin{
JSONData: jd, JSONData: jd,
Class: class, Class: class,
PluginDir: pluginDir, FS: files,
} }
return p.ToDTO() return p.ToDTO()
} }

View File

@ -2,6 +2,7 @@ package plugins
import ( import (
"context" "context"
"io/fs"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
@ -38,6 +39,23 @@ type UpdateInfo struct {
PluginZipURL string PluginZipURL string
} }
type FS interface {
fs.FS
Base() string
Files() []string
}
type FoundBundle struct {
Primary FoundPlugin
Children []*FoundPlugin
}
type FoundPlugin struct {
JSONData JSONData
FS FS
}
// Client is used to communicate with backend plugin implementations. // Client is used to communicate with backend plugin implementations.
type Client interface { type Client interface {
backend.QueryDataHandler backend.QueryDataHandler

91
pkg/plugins/localfiles.go Normal file
View File

@ -0,0 +1,91 @@
package plugins
import (
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/util"
)
var _ fs.FS = (*LocalFS)(nil)
type LocalFS struct {
m map[string]*LocalFile
basePath string
}
func NewLocalFS(m map[string]struct{}, basePath string) LocalFS {
pfs := make(map[string]*LocalFile, len(m))
for k := range m {
pfs[k] = &LocalFile{
path: k,
}
}
return LocalFS{
m: pfs,
basePath: basePath,
}
}
func (f LocalFS) Open(name string) (fs.File, error) {
cleanPath, err := util.CleanRelativePath(name)
if err != nil {
return nil, err
}
if kv, exists := f.m[filepath.Join(f.basePath, cleanPath)]; exists {
if kv.f != nil {
return kv.f, nil
}
return os.Open(kv.path)
}
return nil, ErrFileNotExist
}
func (f LocalFS) Base() string {
return f.basePath
}
func (f LocalFS) Files() []string {
var files []string
for p := range f.m {
r, err := filepath.Rel(f.basePath, p)
if strings.Contains(r, "..") || err != nil {
continue
}
files = append(files, r)
}
return files
}
var _ fs.File = (*LocalFile)(nil)
type LocalFile struct {
f *os.File
path string
}
func (p *LocalFile) Stat() (fs.FileInfo, error) {
return os.Stat(p.path)
}
func (p *LocalFile) Read(bytes []byte) (int, error) {
var err error
p.f, err = os.Open(p.path)
if err != nil {
return 0, err
}
return p.f.Read(bytes)
}
func (p *LocalFile) Close() error {
if p.f != nil {
return p.f.Close()
}
p.f = nil
return nil
}

View File

@ -4,6 +4,7 @@ import (
"archive/zip" "archive/zip"
"context" "context"
"fmt" "fmt"
"io/fs"
"sync" "sync"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
@ -351,6 +352,30 @@ func (f *FakeRoleRegistry) DeclarePluginRoles(_ context.Context, _ string, _ str
return f.ExpectedErr return f.ExpectedErr
} }
type FakePluginFiles struct {
FS fs.FS
base string
}
func NewFakePluginFiles(base string) *FakePluginFiles {
return &FakePluginFiles{
base: base,
}
}
func (f *FakePluginFiles) Open(name string) (fs.File, error) {
return f.FS.Open(name)
}
func (f *FakePluginFiles) Base() string {
return f.base
}
func (f *FakePluginFiles) Files() []string {
return []string{}
}
type FakeSources struct { type FakeSources struct {
ListFunc func(_ context.Context) []plugins.PluginSource ListFunc func(_ context.Context) []plugins.PluginSource
} }

View File

@ -22,13 +22,11 @@ func TestPluginManager_Add_Remove(t *testing.T) {
const ( const (
pluginID, v1 = "test-panel", "1.0.0" pluginID, v1 = "test-panel", "1.0.0"
zipNameV1 = "test-panel-1.0.0.zip" zipNameV1 = "test-panel-1.0.0.zip"
pluginDirV1 = "/data/plugin/test-panel-1.0.0"
) )
// mock a plugin to be returned automatically by the plugin loader // mock a plugin to be returned automatically by the plugin loader
pluginV1 := createPlugin(t, pluginID, plugins.External, true, true, func(plugin *plugins.Plugin) { pluginV1 := createPlugin(t, pluginID, plugins.External, true, true, func(plugin *plugins.Plugin) {
plugin.Info.Version = v1 plugin.Info.Version = v1
plugin.PluginDir = pluginDirV1
}) })
mockZipV1 := &zip.ReadCloser{Reader: zip.Reader{File: []*zip.File{{ mockZipV1 := &zip.ReadCloser{Reader: zip.Reader{File: []*zip.File{{
FileHeader: zip.FileHeader{Name: zipNameV1}, FileHeader: zip.FileHeader{Name: zipNameV1},
@ -63,7 +61,6 @@ func TestPluginManager_Add_Remove(t *testing.T) {
}, },
RegisterFunc: func(_ context.Context, pluginID, pluginDir string) error { RegisterFunc: func(_ context.Context, pluginID, pluginDir string) error {
require.Equal(t, pluginV1.ID, pluginID) require.Equal(t, pluginV1.ID, pluginID)
require.Equal(t, pluginV1.PluginDir, pluginDir)
return nil return nil
}, },
Store: map[string]struct{}{}, Store: map[string]struct{}{},
@ -90,12 +87,10 @@ func TestPluginManager_Add_Remove(t *testing.T) {
const ( const (
v2 = "2.0.0" v2 = "2.0.0"
zipNameV2 = "test-panel-2.0.0.zip" zipNameV2 = "test-panel-2.0.0.zip"
pluginDirV2 = "/data/plugin/test-panel-2.0.0"
) )
// mock a plugin to be returned automatically by the plugin loader // mock a plugin to be returned automatically by the plugin loader
pluginV2 := createPlugin(t, pluginID, plugins.External, true, true, func(plugin *plugins.Plugin) { pluginV2 := createPlugin(t, pluginID, plugins.External, true, true, func(plugin *plugins.Plugin) {
plugin.Info.Version = v2 plugin.Info.Version = v2
plugin.PluginDir = pluginDirV2
}) })
mockZipV2 := &zip.ReadCloser{Reader: zip.Reader{File: []*zip.File{{ mockZipV2 := &zip.ReadCloser{Reader: zip.Reader{File: []*zip.File{{
@ -126,7 +121,6 @@ func TestPluginManager_Add_Remove(t *testing.T) {
} }
fs.RegisterFunc = func(_ context.Context, pluginID, pluginDir string) error { fs.RegisterFunc = func(_ context.Context, pluginID, pluginDir string) error {
require.Equal(t, pluginV2.ID, pluginID) require.Equal(t, pluginV2.ID, pluginID)
require.Equal(t, pluginV2.PluginDir, pluginDir)
return nil return nil
} }

View File

@ -1,90 +1,44 @@
package finder package finder
import ( import (
"errors" "context"
"fmt"
"os"
"path/filepath"
"github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/util"
) )
var walk = util.Walk type Service struct {
local *FS
type Finder struct {
log log.Logger log log.Logger
} }
func New() Finder { func NewService() *Service {
return Finder{log: log.New("plugin.finder")} logger := log.New("plugin.finder")
return &Service{
local: newFS(logger),
log: logger,
}
} }
func (f *Finder) Find(pluginPaths []string) ([]string, error) { func (f *Service) Find(ctx context.Context, pluginPaths ...string) ([]*plugins.FoundBundle, error) {
var pluginJSONPaths []string if len(pluginPaths) == 0 {
return []*plugins.FoundBundle{}, nil
}
fbs := make(map[string][]*plugins.FoundBundle)
for _, path := range pluginPaths { for _, path := range pluginPaths {
exists, err := fs.Exists(path) local, err := f.local.Find(ctx, path)
if err != nil { if err != nil {
f.log.Warn("Error occurred when checking if plugin directory exists", "path", path, "err", err) f.log.Warn("Error occurred when trying to find plugin", "path", path)
}
if !exists {
f.log.Warn("Skipping finding plugins as directory does not exist", "path", path)
continue continue
} }
fbs[path] = local
paths, err := f.getAbsPluginJSONPaths(path)
if err != nil {
return nil, err
}
pluginJSONPaths = append(pluginJSONPaths, paths...)
} }
return pluginJSONPaths, nil var found []*plugins.FoundBundle
for _, fb := range fbs {
found = append(found, fb...)
} }
func (f *Finder) getAbsPluginJSONPaths(path string) ([]string, error) { return found, nil
var pluginJSONPaths []string
var err error
path, err = filepath.Abs(path)
if err != nil {
return []string{}, err
}
if err := walk(path, true, true,
func(currentPath string, fi os.FileInfo, err error) error {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
f.log.Error("Couldn't scan directory since it doesn't exist", "pluginDir", path, "err", err)
return nil
}
if errors.Is(err, os.ErrPermission) {
f.log.Error("Couldn't scan directory due to lack of permissions", "pluginDir", path, "err", err)
return nil
}
return fmt.Errorf("filepath.Walk reported an error for %q: %w", currentPath, err)
}
if fi.Name() == "node_modules" {
return util.ErrWalkSkipDir
}
if fi.IsDir() {
return nil
}
if fi.Name() != "plugin.json" {
return nil
}
pluginJSONPaths = append(pluginJSONPaths, currentPath)
return nil
}); err != nil {
return []string{}, err
}
return pluginJSONPaths, nil
} }

View File

@ -1,121 +0,0 @@
package finder
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFinder_Find(t *testing.T) {
testCases := []struct {
name string
pluginDirs []string
expectedPathSuffix []string
err error
}{
{
name: "Dir with single plugin",
pluginDirs: []string{"../../testdata/valid-v2-signature"},
expectedPathSuffix: []string{"/pkg/plugins/manager/testdata/valid-v2-signature/plugin/plugin.json"},
},
{
name: "Dir with nested plugins",
pluginDirs: []string{"../../testdata/duplicate-plugins"},
expectedPathSuffix: []string{
"/pkg/plugins/manager/testdata/duplicate-plugins/nested/nested/plugin.json",
"/pkg/plugins/manager/testdata/duplicate-plugins/nested/plugin.json",
},
},
{
name: "Dir with single plugin which has symbolic link root directory",
pluginDirs: []string{"../../testdata/symbolic-plugin-dirs"},
expectedPathSuffix: []string{"/pkg/plugins/manager/testdata/includes-symlinks/plugin.json"},
},
{
name: "Multiple plugin dirs",
pluginDirs: []string{"../../testdata/duplicate-plugins", "../../testdata/invalid-v1-signature"},
expectedPathSuffix: []string{
"/pkg/plugins/manager/testdata/duplicate-plugins/nested/nested/plugin.json",
"/pkg/plugins/manager/testdata/duplicate-plugins/nested/plugin.json",
"/pkg/plugins/manager/testdata/invalid-v1-signature/plugin/plugin.json"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
f := New()
pluginPaths, err := f.Find(tc.pluginDirs)
if (err != nil) && !errors.Is(err, tc.err) {
t.Errorf("Find() error = %v, expected error %v", err, tc.err)
return
}
assert.Equal(t, len(tc.expectedPathSuffix), len(pluginPaths))
for i := 0; i < len(tc.expectedPathSuffix); i++ {
assert.True(t, strings.HasSuffix(pluginPaths[i], tc.expectedPathSuffix[i]))
}
})
}
}
func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) {
origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
return walkFn(path, nil, os.ErrNotExist)
}
t.Cleanup(func() {
walk = origWalk
})
finder := &Finder{
log: log.NewTestLogger(),
}
paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err)
require.Empty(t, paths)
})
t.Run("When scanning a folder that lacks permission shouldn't return an error", func(t *testing.T) {
origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
return walkFn(path, nil, os.ErrPermission)
}
t.Cleanup(func() {
walk = origWalk
})
finder := &Finder{
log: log.NewTestLogger(),
}
paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err)
require.Empty(t, paths)
})
t.Run("When scanning a folder that returns a non-handled error should return that error", func(t *testing.T) {
origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
return walkFn(path, nil, fmt.Errorf("random error"))
}
t.Cleanup(func() {
walk = origWalk
})
finder := &Finder{
log: log.NewTestLogger(),
}
paths, err := finder.getAbsPluginJSONPaths("test")
require.Error(t, err)
require.Empty(t, paths)
})
}

View File

@ -0,0 +1,286 @@
package finder
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
)
var walk = util.Walk
var (
ErrInvalidPluginJSON = errors.New("did not find valid type or id properties in plugin.json")
ErrInvalidPluginJSONFilePath = errors.New("invalid plugin.json filepath was provided")
)
type FS struct {
log log.Logger
}
func newFS(logger log.Logger) *FS {
return &FS{log: logger.New("fs")}
}
func (f *FS) Find(_ context.Context, pluginPaths ...string) ([]*plugins.FoundBundle, error) {
if len(pluginPaths) == 0 {
return []*plugins.FoundBundle{}, nil
}
var pluginJSONPaths []string
for _, path := range pluginPaths {
exists, err := fs.Exists(path)
if err != nil {
f.log.Warn("Skipping finding plugins as an error occurred", "path", path, "err", err)
continue
}
if !exists {
f.log.Warn("Skipping finding plugins as directory does not exist", "path", path)
continue
}
paths, err := f.getAbsPluginJSONPaths(path)
if err != nil {
return nil, err
}
pluginJSONPaths = append(pluginJSONPaths, paths...)
}
// load plugin.json files and map directory to JSON data
foundPlugins := make(map[string]plugins.JSONData)
for _, pluginJSONPath := range pluginJSONPaths {
plugin, err := f.readPluginJSON(pluginJSONPath)
if err != nil {
f.log.Warn("Skipping plugin loading as its plugin.json could not be read", "path", pluginJSONPath, "err", err)
continue
}
pluginJSONAbsPath, err := filepath.Abs(pluginJSONPath)
if err != nil {
f.log.Warn("Skipping plugin loading as absolute plugin.json path could not be calculated", "pluginID", plugin.ID, "err", err)
continue
}
if _, dupe := foundPlugins[filepath.Dir(pluginJSONAbsPath)]; dupe {
f.log.Warn("Skipping plugin loading as it's a duplicate", "pluginID", plugin.ID)
continue
}
foundPlugins[filepath.Dir(pluginJSONAbsPath)] = plugin
}
var res = make(map[string]*plugins.FoundBundle)
for pluginDir, data := range foundPlugins {
files, err := collectFilesWithin(pluginDir)
if err != nil {
return nil, err
}
res[pluginDir] = &plugins.FoundBundle{
Primary: plugins.FoundPlugin{
JSONData: data,
FS: plugins.NewLocalFS(files, pluginDir),
},
}
}
var result []*plugins.FoundBundle
for dir := range foundPlugins {
ancestors := strings.Split(dir, string(filepath.Separator))
ancestors = ancestors[0 : len(ancestors)-1]
pluginPath := ""
if runtime.GOOS != "windows" && filepath.IsAbs(dir) {
pluginPath = "/"
}
add := true
for _, ancestor := range ancestors {
pluginPath = filepath.Join(pluginPath, ancestor)
if _, ok := foundPlugins[pluginPath]; ok {
if fp, exists := res[pluginPath]; exists {
fp.Children = append(fp.Children, &res[dir].Primary)
add = false
break
}
}
}
if add {
result = append(result, res[dir])
}
}
return result, nil
}
func (f *FS) getAbsPluginJSONPaths(path string) ([]string, error) {
var pluginJSONPaths []string
var err error
path, err = filepath.Abs(path)
if err != nil {
return []string{}, err
}
if err = walk(path, true, true,
func(currentPath string, fi os.FileInfo, err error) error {
if err != nil {
if errors.Is(err, os.ErrNotExist) {
f.log.Error("Couldn't scan directory since it doesn't exist", "pluginDir", path, "err", err)
return nil
}
if errors.Is(err, os.ErrPermission) {
f.log.Error("Couldn't scan directory due to lack of permissions", "pluginDir", path, "err", err)
return nil
}
return fmt.Errorf("filepath.Walk reported an error for %q: %w", currentPath, err)
}
if fi.Name() == "node_modules" {
return util.ErrWalkSkipDir
}
if fi.IsDir() {
return nil
}
if fi.Name() != "plugin.json" {
return nil
}
pluginJSONPaths = append(pluginJSONPaths, currentPath)
return nil
}); err != nil {
return []string{}, err
}
return pluginJSONPaths, nil
}
func (f *FS) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) {
f.log.Debug("Loading plugin", "path", pluginJSONPath)
if !strings.EqualFold(filepath.Ext(pluginJSONPath), ".json") {
return plugins.JSONData{}, ErrInvalidPluginJSONFilePath
}
absPluginJSONPath, err := filepath.Abs(pluginJSONPath)
if err != nil {
return plugins.JSONData{}, err
}
// Wrapping in filepath.Clean to properly handle
// gosec G304 Potential file inclusion via variable rule.
reader, err := os.Open(filepath.Clean(absPluginJSONPath))
if err != nil {
return plugins.JSONData{}, err
}
defer func() {
if reader == nil {
return
}
if err = reader.Close(); err != nil {
f.log.Warn("Failed to close JSON file", "path", pluginJSONPath, "err", err)
}
}()
plugin := plugins.JSONData{}
if err = json.NewDecoder(reader).Decode(&plugin); err != nil {
return plugins.JSONData{}, err
}
if err = validatePluginJSON(plugin); err != nil {
return plugins.JSONData{}, err
}
if plugin.ID == "grafana-piechart-panel" {
plugin.Name = "Pie Chart (old)"
}
if len(plugin.Dependencies.Plugins) == 0 {
plugin.Dependencies.Plugins = []plugins.Dependency{}
}
if plugin.Dependencies.GrafanaVersion == "" {
plugin.Dependencies.GrafanaVersion = "*"
}
for _, include := range plugin.Includes {
if include.Role == "" {
include.Role = org.RoleViewer
}
}
return plugin, nil
}
func validatePluginJSON(data plugins.JSONData) error {
if data.ID == "" || !data.Type.IsValid() {
return ErrInvalidPluginJSON
}
return nil
}
func collectFilesWithin(dir string) (map[string]struct{}, error) {
files := map[string]struct{}{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
symlinkPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
symlink, err := os.Stat(symlinkPath)
if err != nil {
return err
}
// verify that symlinked file is within plugin directory
p, err := filepath.Rel(dir, symlinkPath)
if err != nil {
return err
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return fmt.Errorf("file '%s' not inside of plugin directory", p)
}
// skip adding symlinked directories
if symlink.IsDir() {
return nil
}
}
// skip directories
if info.IsDir() {
return nil
}
// verify that file is within plugin directory
file, err := filepath.Rel(dir, path)
if err != nil {
return err
}
if strings.HasPrefix(file, ".."+string(filepath.Separator)) {
return fmt.Errorf("file '%s' not inside of plugin directory", file)
}
files[path] = struct{}{}
return nil
})
return files, err
}

View File

@ -0,0 +1,485 @@
package finder
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
)
func TestFinder_Find(t *testing.T) {
testData, err := filepath.Abs("../../testdata")
if err != nil {
require.NoError(t, err)
}
testCases := []struct {
name string
pluginDirs []string
expectedBundles []*plugins.FoundBundle
err error
}{
{
name: "Dir with single plugin",
pluginDirs: []string{filepath.Join(testData, "valid-v2-signature")},
expectedBundles: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-datasource",
Type: plugins.DataSource,
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Will Browne",
URL: "https://willbrowne.com",
},
Description: "Test",
Version: "1.0.0",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
State: plugins.AlphaRelease,
Backend: true,
Executable: "test",
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "valid-v2-signature/plugin/plugin.json"): {},
filepath.Join(testData, "valid-v2-signature/plugin/MANIFEST.txt"): {},
}, filepath.Join(testData, "valid-v2-signature/plugin")),
},
},
},
},
{
name: "Dir with nested plugins",
pluginDirs: []string{"../../testdata/duplicate-plugins"},
expectedBundles: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.DataSource,
Name: "Parent",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "http://grafana.com",
},
Description: "Parent plugin",
Version: "1.0.0",
Updated: "2020-10-20",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "duplicate-plugins/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/MANIFEST.txt"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested")),
},
Children: []*plugins.FoundPlugin{
{
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.DataSource,
Name: "Child",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "http://grafana.com",
},
Description: "Child plugin",
Version: "1.0.0",
Updated: "2020-10-20",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested/nested")),
},
},
},
},
},
{
name: "Dir with single plugin which has symbolic link root directory",
pluginDirs: []string{"../../testdata/symbolic-plugin-dirs"},
expectedBundles: []*plugins.FoundBundle{
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.App,
Name: "Test App",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Test Inc.",
URL: "http://test.com",
},
Description: "Official Grafana Test App & Dashboard bundle",
Version: "1.0.0",
Links: []plugins.InfoLink{
{Name: "Project site", URL: "http://project.com"},
{Name: "License & Terms", URL: "http://license.com"},
},
Updated: "2015-02-10",
Logos: plugins.Logos{
Small: "img/logo_small.png",
Large: "img/logo_large.png",
},
Screenshots: []plugins.Screenshots{
{Name: "img1", Path: "img/screenshot1.png"},
{Name: "img2", Path: "img/screenshot2.png"},
},
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "3.x.x",
Plugins: []plugins.Dependency{
{ID: "graphite", Type: "datasource", Name: "Graphite", Version: "1.0.0"},
{ID: "graph", Type: "panel", Name: "Graph", Version: "1.0.0"},
},
},
Includes: []*plugins.Includes{
{
Name: "Nginx Connections",
Path: "dashboards/connections.json",
Type: "dashboard",
Role: "Viewer",
},
{
Name: "Nginx Memory",
Path: "dashboards/memory.json",
Type: "dashboard",
Role: "Viewer",
},
{Name: "Nginx Panel", Type: "panel", Role: "Viewer"},
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer"},
},
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "includes-symlinks/MANIFEST.txt"): {},
filepath.Join(testData, "includes-symlinks/dashboards/connections.json"): {},
filepath.Join(testData, "includes-symlinks/dashboards/extra/memory.json"): {},
filepath.Join(testData, "includes-symlinks/plugin.json"): {},
filepath.Join(testData, "includes-symlinks/symlink_to_txt"): {},
filepath.Join(testData, "includes-symlinks/text.txt"): {},
}, filepath.Join(testData, "includes-symlinks")),
},
},
},
},
{
name: "Multiple plugin dirs",
pluginDirs: []string{"../../testdata/duplicate-plugins", "../../testdata/invalid-v1-signature"},
expectedBundles: []*plugins.FoundBundle{{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.DataSource,
Name: "Parent",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "http://grafana.com",
},
Description: "Parent plugin",
Version: "1.0.0",
Updated: "2020-10-20",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "duplicate-plugins/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/MANIFEST.txt"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested")),
},
Children: []*plugins.FoundPlugin{
{
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.DataSource,
Name: "Child",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "http://grafana.com",
},
Description: "Child plugin",
Version: "1.0.0",
Updated: "2020-10-20",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "duplicate-plugins/nested/nested/plugin.json"): {},
filepath.Join(testData, "duplicate-plugins/nested/nested/MANIFEST.txt"): {},
}, filepath.Join(testData, "duplicate-plugins/nested/nested")),
},
},
},
{
Primary: plugins.FoundPlugin{
JSONData: plugins.JSONData{
ID: "test-datasource",
Type: plugins.DataSource,
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "https://grafana.com",
},
Description: "Test",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
State: plugins.AlphaRelease,
Backend: true,
},
FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(testData, "invalid-v1-signature/plugin/plugin.json"): {},
filepath.Join(testData, "invalid-v1-signature/plugin/MANIFEST.txt"): {},
}, filepath.Join(testData, "invalid-v1-signature/plugin")),
},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
f := newFS(log.NewTestLogger())
pluginBundles, err := f.Find(context.Background(), tc.pluginDirs...)
if (err != nil) && !errors.Is(err, tc.err) {
t.Errorf("Find() error = %v, expected error %v", err, tc.err)
return
}
// to ensure we can compare with expected
sort.SliceStable(pluginBundles, func(i, j int) bool {
return pluginBundles[i].Primary.JSONData.ID < pluginBundles[j].Primary.JSONData.ID
})
if !cmp.Equal(pluginBundles, tc.expectedBundles, localFSComparer) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(pluginBundles, tc.expectedBundles, localFSComparer))
}
})
}
}
func TestFinder_getAbsPluginJSONPaths(t *testing.T) {
t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) {
origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
return walkFn(path, nil, os.ErrNotExist)
}
t.Cleanup(func() {
walk = origWalk
})
finder := newFS(log.NewTestLogger())
paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err)
require.Empty(t, paths)
})
t.Run("When scanning a folder that lacks permission shouldn't return an error", func(t *testing.T) {
origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
return walkFn(path, nil, os.ErrPermission)
}
t.Cleanup(func() {
walk = origWalk
})
finder := newFS(log.NewTestLogger())
paths, err := finder.getAbsPluginJSONPaths("test")
require.NoError(t, err)
require.Empty(t, paths)
})
t.Run("When scanning a folder that returns a non-handled error should return that error", func(t *testing.T) {
origWalk := walk
walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error {
return walkFn(path, nil, fmt.Errorf("random error"))
}
t.Cleanup(func() {
walk = origWalk
})
finder := newFS(log.NewTestLogger())
paths, err := finder.getAbsPluginJSONPaths("test")
require.Error(t, err)
require.Empty(t, paths)
})
}
func TestFinder_validatePluginJSON(t *testing.T) {
type args struct {
data plugins.JSONData
}
tests := []struct {
name string
args args
err error
}{
{
name: "Valid case",
args: args{
data: plugins.JSONData{
ID: "grafana-plugin-id",
Type: plugins.DataSource,
},
},
},
{
name: "Invalid plugin ID",
args: args{
data: plugins.JSONData{
Type: plugins.Panel,
},
},
err: ErrInvalidPluginJSON,
},
{
name: "Invalid plugin type",
args: args{
data: plugins.JSONData{
ID: "grafana-plugin-id",
Type: "test",
},
},
err: ErrInvalidPluginJSON,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validatePluginJSON(tt.args.data); !errors.Is(err, tt.err) {
t.Errorf("validatePluginJSON() = %v, want %v", err, tt.err)
}
})
}
}
func TestFinder_readPluginJSON(t *testing.T) {
tests := []struct {
name string
pluginPath string
expected plugins.JSONData
failed bool
}{
{
name: "Valid plugin",
pluginPath: "../../testdata/test-app/plugin.json",
expected: plugins.JSONData{
ID: "test-app",
Type: "app",
Name: "Test App",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Test Inc.",
URL: "http://test.com",
},
Description: "Official Grafana Test App & Dashboard bundle",
Version: "1.0.0",
Links: []plugins.InfoLink{
{Name: "Project site", URL: "http://project.com"},
{Name: "License & Terms", URL: "http://license.com"},
},
Logos: plugins.Logos{
Small: "img/logo_small.png",
Large: "img/logo_large.png",
},
Screenshots: []plugins.Screenshots{
{Path: "img/screenshot1.png", Name: "img1"},
{Path: "img/screenshot2.png", Name: "img2"},
},
Updated: "2015-02-10",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "3.x.x",
Plugins: []plugins.Dependency{
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
},
Includes: []*plugins.Includes{
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer},
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer},
{Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer},
{Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer},
},
Backend: false,
},
},
{
name: "Invalid plugin JSON",
pluginPath: "../testdata/invalid-plugin-json/plugin.json",
failed: true,
},
{
name: "Non-existing JSON file",
pluginPath: "nonExistingFile.json",
failed: true,
},
}
f := newFS(log.NewTestLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := f.readPluginJSON(tt.pluginPath)
if (err != nil) && !tt.failed {
t.Errorf("readPluginJSON() error = %v, failed %v", err, tt.failed)
return
}
if !cmp.Equal(got, tt.expected) {
t.Errorf("Unexpected pluginJSONData: %v", cmp.Diff(got, tt.expected))
}
})
}
}
var localFSComparer = cmp.Comparer(func(fs1 plugins.LocalFS, fs2 plugins.LocalFS) bool {
fs1Files := fs1.Files()
fs2Files := fs2.Files()
sort.SliceStable(fs1Files, func(i, j int) bool {
return fs1Files[i] < fs1Files[j]
})
sort.SliceStable(fs2Files, func(i, j int) bool {
return fs2Files[i] < fs2Files[j]
})
return cmp.Equal(fs1Files, fs2Files) && fs1.Base() == fs2.Base()
})

View File

@ -0,0 +1,11 @@
package finder
import (
"context"
"github.com/grafana/grafana/pkg/plugins"
)
type Finder interface {
Find(ctx context.Context, uris ...string) ([]*plugins.FoundBundle, error)
}

View File

@ -2,7 +2,6 @@ package initializer
import ( import (
"context" "context"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -15,9 +14,6 @@ import (
) )
func TestInitializer_Initialize(t *testing.T) { func TestInitializer_Initialize(t *testing.T) {
absCurPath, err := filepath.Abs(".")
assert.NoError(t, err)
t.Run("core backend datasource", func(t *testing.T) { t.Run("core backend datasource", func(t *testing.T) {
p := &plugins.Plugin{ p := &plugins.Plugin{
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
@ -31,7 +27,6 @@ func TestInitializer_Initialize(t *testing.T) {
}, },
Backend: true, Backend: true,
}, },
PluginDir: absCurPath,
Class: plugins.Core, Class: plugins.Core,
} }
@ -61,7 +56,6 @@ func TestInitializer_Initialize(t *testing.T) {
}, },
Backend: true, Backend: true,
}, },
PluginDir: absCurPath,
Class: plugins.External, Class: plugins.External,
} }
@ -91,7 +85,6 @@ func TestInitializer_Initialize(t *testing.T) {
}, },
Backend: true, Backend: true,
}, },
PluginDir: absCurPath,
Class: plugins.External, Class: plugins.External,
} }

View File

@ -2,16 +2,11 @@ package loader
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"path" "path"
"path/filepath"
"runtime"
"strings" "strings"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
@ -25,15 +20,9 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/plugins/storage" "github.com/grafana/grafana/pkg/plugins/storage"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
var (
ErrInvalidPluginJSON = errors.New("did not find valid type or id properties in plugin.json")
ErrInvalidPluginJSONFilePath = errors.New("invalid plugin.json filepath was provided")
)
var _ plugins.ErrorResolver = (*Loader)(nil) var _ plugins.ErrorResolver = (*Loader)(nil)
type Loader struct { type Loader struct {
@ -64,7 +53,7 @@ func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLo
processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry, processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry,
pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader { pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader {
return &Loader{ return &Loader{
pluginFinder: finder.New(), pluginFinder: finder.NewService(),
pluginRegistry: pluginRegistry, pluginRegistry: pluginRegistry,
pluginInitializer: initializer.New(cfg, backendProvider, license), pluginInitializer: initializer.New(cfg, backendProvider, license),
signatureValidator: signature.NewValidator(authorizer), signatureValidator: signature.NewValidator(authorizer),
@ -80,95 +69,65 @@ func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLo
} }
func (l *Loader) Load(ctx context.Context, class plugins.Class, paths []string) ([]*plugins.Plugin, error) { func (l *Loader) Load(ctx context.Context, class plugins.Class, paths []string) ([]*plugins.Plugin, error) {
pluginJSONPaths, err := l.pluginFinder.Find(paths) found, err := l.pluginFinder.Find(ctx, paths...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return l.loadPlugins(ctx, class, pluginJSONPaths) return l.loadPlugins(ctx, class, found)
} }
func (l *Loader) createPluginsForLoading(class plugins.Class, foundPlugins foundPlugins) map[string]*plugins.Plugin { func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, found []*plugins.FoundBundle) ([]*plugins.Plugin, error) {
loadedPlugins := make(map[string]*plugins.Plugin) var loadedPlugins []*plugins.Plugin
for pluginDir, pluginJSON := range foundPlugins { for _, p := range found {
plugin, err := l.createPluginBase(pluginJSON, class, pluginDir) if _, exists := l.pluginRegistry.Plugin(ctx, p.Primary.JSONData.ID); exists {
if err != nil { l.log.Warn("Skipping plugin loading as it's a duplicate", "pluginID", p.Primary.JSONData.ID)
l.log.Warn("Could not create plugin base", "pluginID", pluginJSON.ID, "err", err)
continue continue
} }
// calculate initial signature state
var sig plugins.Signature var sig plugins.Signature
if l.pluginsCDN.PluginSupported(plugin.ID) { if l.pluginsCDN.PluginSupported(p.Primary.JSONData.ID) {
// CDN plugins have no signature checks for now. // CDN plugins have no signature checks for now.
sig = plugins.Signature{Status: plugins.SignatureValid} sig = plugins.Signature{Status: plugins.SignatureValid}
} else { } else {
sig, err = signature.Calculate(l.log, plugin) var err error
sig, err = signature.Calculate(l.log, class, p.Primary)
if err != nil { if err != nil {
l.log.Warn("Could not calculate plugin signature state", "pluginID", plugin.ID, "err", err) l.log.Warn("Could not calculate plugin signature state", "pluginID", p.Primary.JSONData.ID, "err", err)
continue continue
} }
} }
plugin, err := l.createPluginBase(p.Primary.JSONData, class, p.Primary.FS)
if err != nil {
l.log.Error("Could not create primary plugin base", "pluginID", p.Primary.JSONData.ID, "err", err)
continue
}
plugin.Signature = sig.Status plugin.Signature = sig.Status
plugin.SignatureType = sig.Type plugin.SignatureType = sig.Type
plugin.SignatureOrg = sig.SigningOrg plugin.SignatureOrg = sig.SigningOrg
loadedPlugins[plugin.PluginDir] = plugin loadedPlugins = append(loadedPlugins, plugin)
}
return loadedPlugins for _, c := range p.Children {
if _, exists := l.pluginRegistry.Plugin(ctx, c.JSONData.ID); exists {
l.log.Warn("Skipping plugin loading as it's a duplicate", "pluginID", p.Primary.JSONData.ID)
continue
} }
func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSONPaths []string) ([]*plugins.Plugin, error) { cp, err := l.createPluginBase(c.JSONData, class, c.FS)
var foundPlugins = foundPlugins{}
// load plugin.json files and map directory to JSON data
for _, pluginJSONPath := range pluginJSONPaths {
plugin, err := l.readPluginJSON(pluginJSONPath)
if err != nil { if err != nil {
l.log.Warn("Skipping plugin loading as its plugin.json could not be read", "path", pluginJSONPath, "err", err) l.log.Error("Could not create child plugin base", "pluginID", p.Primary.JSONData.ID, "err", err)
continue continue
} }
cp.Parent = plugin
cp.Signature = sig.Status
cp.SignatureType = sig.Type
cp.SignatureOrg = sig.SigningOrg
pluginJSONAbsPath, err := filepath.Abs(pluginJSONPath) plugin.Children = append(plugin.Children, cp)
if err != nil {
l.log.Warn("Skipping plugin loading as absolute plugin.json path could not be calculated", "pluginID", plugin.ID, "err", err)
continue
}
if _, dupe := foundPlugins[filepath.Dir(pluginJSONAbsPath)]; dupe { loadedPlugins = append(loadedPlugins, cp)
l.log.Warn("Skipping plugin loading as it's a duplicate", "pluginID", plugin.ID)
continue
}
foundPlugins[filepath.Dir(pluginJSONAbsPath)] = plugin
}
// get all registered plugins
registeredPlugins := make(map[string]struct{})
for _, p := range l.pluginRegistry.Plugins(ctx) {
registeredPlugins[p.ID] = struct{}{}
}
foundPlugins.stripDuplicates(registeredPlugins, l.log)
// create plugins structs and calculate signatures
loadedPlugins := l.createPluginsForLoading(class, foundPlugins)
// wire up plugin dependencies
for _, plugin := range loadedPlugins {
ancestors := strings.Split(plugin.PluginDir, string(filepath.Separator))
ancestors = ancestors[0 : len(ancestors)-1]
pluginPath := ""
if runtime.GOOS != "windows" && filepath.IsAbs(plugin.PluginDir) {
pluginPath = "/"
}
for _, ancestor := range ancestors {
pluginPath = filepath.Join(pluginPath, ancestor)
if parentPlugin, ok := loadedPlugins[pluginPath]; ok {
plugin.Parent = parentPlugin
plugin.Parent.Children = append(plugin.Parent.Children, plugin)
break
}
} }
} }
@ -191,14 +150,12 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
// verify module.js exists for SystemJS to load. // verify module.js exists for SystemJS to load.
// CDN plugins can be loaded with plugin.json only, so do not warn for those. // CDN plugins can be loaded with plugin.json only, so do not warn for those.
if !plugin.IsRenderer() && !plugin.IsCorePlugin() { if !plugin.IsRenderer() && !plugin.IsCorePlugin() {
module := filepath.Join(plugin.PluginDir, "module.js") _, err := plugin.FS.Open("module.js")
if exists, err := fs.Exists(module); err != nil { if err != nil {
return nil, err if errors.Is(err, plugins.ErrFileNotExist) && !l.pluginsCDN.PluginSupported(plugin.ID) {
} else if !exists && !l.pluginsCDN.PluginSupported(plugin.ID) { l.log.Warn("Plugin missing module.js", "pluginID", plugin.ID,
l.log.Warn("Plugin missing module.js", "warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.")
"pluginID", plugin.ID, }
"warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.",
"path", module)
} }
} }
@ -221,7 +178,7 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
metrics.SetPluginBuildInformation(p.ID, string(p.Type), p.Info.Version, string(p.Signature)) metrics.SetPluginBuildInformation(p.ID, string(p.Type), p.Info.Version, string(p.Signature))
if errDeclareRoles := l.roleRegistry.DeclarePluginRoles(ctx, p.ID, p.Name, p.Roles); errDeclareRoles != nil { if errDeclareRoles := l.roleRegistry.DeclarePluginRoles(ctx, p.ID, p.Name, p.Roles); errDeclareRoles != nil {
l.log.Warn("Declare plugin roles failed.", "pluginID", p.ID, "path", p.PluginDir, "error", errDeclareRoles) l.log.Warn("Declare plugin roles failed.", "pluginID", p.ID, "err", errDeclareRoles)
} }
} }
@ -260,7 +217,7 @@ func (l *Loader) load(ctx context.Context, p *plugins.Plugin) error {
} }
if p.IsExternalPlugin() { if p.IsExternalPlugin() {
if err := l.pluginStorage.Register(ctx, p.ID, p.PluginDir); err != nil { if err := l.pluginStorage.Register(ctx, p.ID, p.FS.Base()); err != nil {
return err return err
} }
} }
@ -271,7 +228,6 @@ func (l *Loader) load(ctx context.Context, p *plugins.Plugin) error {
func (l *Loader) unload(ctx context.Context, p *plugins.Plugin) error { func (l *Loader) unload(ctx context.Context, p *plugins.Plugin) error {
l.log.Debug("Stopping plugin process", "pluginId", p.ID) l.log.Debug("Stopping plugin process", "pluginId", p.ID)
// TODO confirm the sequence of events is safe
if err := l.processManager.Stop(ctx, p.ID); err != nil { if err := l.processManager.Stop(ctx, p.ID); err != nil {
return err return err
} }
@ -287,67 +243,18 @@ func (l *Loader) unload(ctx context.Context, p *plugins.Plugin) error {
return nil return nil
} }
func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) { func (l *Loader) createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, files plugins.FS) (*plugins.Plugin, error) {
l.log.Debug("Loading plugin", "path", pluginJSONPath) baseURL, err := l.assetPath.Base(pluginJSON, class, files.Base())
if !strings.EqualFold(filepath.Ext(pluginJSONPath), ".json") {
return plugins.JSONData{}, ErrInvalidPluginJSONFilePath
}
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `currentPath` is based
// on plugin the folder structure on disk and not user input.
reader, err := os.Open(pluginJSONPath)
if err != nil {
return plugins.JSONData{}, err
}
plugin := plugins.JSONData{}
if err = json.NewDecoder(reader).Decode(&plugin); err != nil {
return plugins.JSONData{}, err
}
if err = reader.Close(); err != nil {
l.log.Warn("Failed to close JSON file", "path", pluginJSONPath, "err", err)
}
if err = validatePluginJSON(plugin); err != nil {
return plugins.JSONData{}, err
}
if plugin.ID == "grafana-piechart-panel" {
plugin.Name = "Pie Chart (old)"
}
if len(plugin.Dependencies.Plugins) == 0 {
plugin.Dependencies.Plugins = []plugins.Dependency{}
}
if plugin.Dependencies.GrafanaVersion == "" {
plugin.Dependencies.GrafanaVersion = "*"
}
for _, include := range plugin.Includes {
if include.Role == "" {
include.Role = org.RoleViewer
}
}
return plugin, nil
}
func (l *Loader) createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (*plugins.Plugin, error) {
baseURL, err := l.assetPath.Base(pluginJSON, class, pluginDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("base url: %w", err) return nil, fmt.Errorf("base url: %w", err)
} }
moduleURL, err := l.assetPath.Module(pluginJSON, class, pluginDir) moduleURL, err := l.assetPath.Module(pluginJSON, class, files.Base())
if err != nil { if err != nil {
return nil, fmt.Errorf("module url: %w", err) return nil, fmt.Errorf("module url: %w", err)
} }
plugin := &plugins.Plugin{ plugin := &plugins.Plugin{
JSONData: pluginJSON, JSONData: pluginJSON,
PluginDir: pluginDir, FS: files,
BaseURL: baseURL, BaseURL: baseURL,
Module: moduleURL, Module: moduleURL,
Class: class, Class: class,
@ -409,7 +316,7 @@ func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) {
if !parent.IsApp() { if !parent.IsApp() {
return return
} }
appSubPath := strings.ReplaceAll(strings.Replace(child.PluginDir, parent.PluginDir, "", 1), "\\", "/") appSubPath := strings.ReplaceAll(strings.Replace(child.FS.Base(), parent.FS.Base(), "", 1), "\\", "/")
child.IncludedInAppID = parent.ID child.IncludedInAppID = parent.ID
child.BaseURL = parent.BaseURL child.BaseURL = parent.BaseURL
@ -435,26 +342,3 @@ func (l *Loader) PluginErrors() []*plugins.Error {
return errs return errs
} }
func validatePluginJSON(data plugins.JSONData) error {
if data.ID == "" || !data.Type.IsValid() {
return ErrInvalidPluginJSON
}
return nil
}
type foundPlugins map[string]plugins.JSONData
// stripDuplicates will strip duplicate plugins or plugins that already exist
func (f *foundPlugins) stripDuplicates(existingPlugins map[string]struct{}, log log.Logger) {
pluginsByID := make(map[string]struct{})
for k, scannedPlugin := range *f {
if _, existing := existingPlugins[scannedPlugin.ID]; existing {
log.Debug("Skipping plugin as it's already installed", "plugin", scannedPlugin.ID)
delete(*f, k)
continue
}
pluginsByID[scannedPlugin.ID] = struct{}{}
}
}

View File

@ -2,7 +2,7 @@ package loader
import ( import (
"context" "context"
"errors" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"testing" "testing"
@ -20,11 +20,25 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/plugins/manager/loader/initializer" "github.com/grafana/grafana/pkg/plugins/manager/loader/initializer"
"github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
var compareOpts = cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log") var compareOpts = []cmp.Option{cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log"), localFSComparer}
var localFSComparer = cmp.Comparer(func(fs1 plugins.LocalFS, fs2 plugins.LocalFS) bool {
fs1Files := fs1.Files()
fs2Files := fs2.Files()
sort.SliceStable(fs1Files, func(i, j int) bool {
return fs1Files[i] < fs1Files[j]
})
sort.SliceStable(fs2Files, func(i, j int) bool {
return fs2Files[i] < fs2Files[j]
})
return cmp.Equal(fs1Files, fs2Files) && fs1.Base() == fs2.Base()
})
func TestLoader_Load(t *testing.T) { func TestLoader_Load(t *testing.T) {
corePluginDir, err := filepath.Abs("./../../../../public") corePluginDir, err := filepath.Abs("./../../../../public")
@ -88,7 +102,9 @@ func TestLoader_Load(t *testing.T) {
}, },
Module: "app/plugins/datasource/cloudwatch/module", Module: "app/plugins/datasource/cloudwatch/module",
BaseURL: "public/app/plugins/datasource/cloudwatch", BaseURL: "public/app/plugins/datasource/cloudwatch",
PluginDir: filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch"), FS: plugins.NewLocalFS(
filesInDir(t, filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")),
filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")),
Signature: plugins.SignatureInternal, Signature: plugins.SignatureInternal,
Class: plugins.Core, Class: plugins.Core,
}, },
@ -127,7 +143,10 @@ func TestLoader_Load(t *testing.T) {
}, },
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/"), FS: plugins.NewLocalFS(
filesInDir(t, filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/")),
filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/"),
),
Signature: "valid", Signature: "valid",
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -204,7 +223,17 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-app/module", Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app", BaseURL: "public/plugins/test-app",
PluginDir: filepath.Join(parentDir, "testdata/includes-symlinks"), FS: plugins.NewLocalFS(
map[string]struct{}{
filepath.Join(parentDir, "testdata/includes-symlinks", "/MANIFEST.txt"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "dashboards/connections.json"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "dashboards/extra/memory.json"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "plugin.json"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "symlink_to_txt"): {},
filepath.Join(parentDir, "testdata/includes-symlinks", "text.txt"): {},
},
filepath.Join(parentDir, "testdata/includes-symlinks"),
),
Signature: "valid", Signature: "valid",
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -244,7 +273,10 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"), FS: plugins.NewLocalFS(
filesInDir(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
),
Signature: "unsigned", Signature: "unsigned",
}, },
}, },
@ -295,7 +327,10 @@ func TestLoader_Load(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"), FS: plugins.NewLocalFS(
filesInDir(t, filepath.Join(parentDir, "testdata/unsigned-datasource/plugin")),
filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
),
Signature: plugins.SignatureUnsigned, Signature: plugins.SignatureUnsigned,
}, },
}, },
@ -399,7 +434,10 @@ func TestLoader_Load(t *testing.T) {
Backend: false, Backend: false,
}, },
DefaultNavURL: "/plugins/test-app/page/root-page-react", DefaultNavURL: "/plugins/test-app/page/root-page-react",
PluginDir: filepath.Join(parentDir, "testdata/test-app-with-includes"), FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(parentDir, "testdata/test-app-with-includes", "dashboards/memory.json"): {},
filepath.Join(parentDir, "testdata/test-app-with-includes", "plugin.json"): {},
}, filepath.Join(parentDir, "testdata/test-app-with-includes")),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureUnsigned, Signature: plugins.SignatureUnsigned,
Module: "plugins/test-app/module", Module: "plugins/test-app/module",
@ -454,7 +492,9 @@ func TestLoader_Load(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
PluginDir: filepath.Join(parentDir, "testdata/cdn/plugin"), FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(parentDir, "testdata/cdn/plugin", "plugin.json"): {},
}, filepath.Join(parentDir, "testdata/cdn/plugin")),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel", BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel",
@ -478,8 +518,8 @@ func TestLoader_Load(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := l.Load(context.Background(), tt.class, tt.pluginPaths) got, err := l.Load(context.Background(), tt.class, tt.pluginPaths)
require.NoError(t, err) require.NoError(t, err)
if !cmp.Equal(got, tt.want, compareOpts) { if !cmp.Equal(got, tt.want, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts...))
} }
pluginErrs := l.PluginErrors() pluginErrs := l.PluginErrors()
@ -603,7 +643,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
Class: plugins.External, Class: plugins.External,
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin"), FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin/plugin.json"): {},
filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin/MANIFEST.txt"): {},
}, filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin")),
Signature: "valid", Signature: "valid",
SignatureType: plugins.PrivateSignature, SignatureType: plugins.PrivateSignature,
SignatureOrg: "Will Browne", SignatureOrg: "Will Browne",
@ -641,8 +684,8 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
sort.SliceStable(got, func(i, j int) bool { sort.SliceStable(got, func(i, j int) bool {
return got[i].ID < got[j].ID return got[i].ID < got[j].ID
}) })
if !cmp.Equal(got, tt.want, compareOpts) { if !cmp.Equal(got, tt.want, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts...))
} }
pluginErrs := l.PluginErrors() pluginErrs := l.PluginErrors()
require.Equal(t, len(tt.pluginErrors), len(pluginErrs)) require.Equal(t, len(tt.pluginErrors), len(pluginErrs))
@ -717,7 +760,10 @@ func TestLoader_Load_RBACReady(t *testing.T) {
}, },
Backend: false, Backend: false,
}, },
PluginDir: pluginDir, FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(pluginDir, "plugin.json"): {},
filepath.Join(pluginDir, "MANIFEST.txt"): {},
}, pluginDir),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.PrivateSignature, SignatureType: plugins.PrivateSignature,
@ -749,8 +795,8 @@ func TestLoader_Load_RBACReady(t *testing.T) {
got, err := l.Load(context.Background(), plugins.External, tt.pluginPaths) got, err := l.Load(context.Background(), plugins.External, tt.pluginPaths)
require.NoError(t, err) require.NoError(t, err)
if !cmp.Equal(got, tt.want, compareOpts) { if !cmp.Equal(got, tt.want, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts...))
} }
pluginErrs := l.PluginErrors() pluginErrs := l.PluginErrors()
require.Len(t, pluginErrs, 0) require.Len(t, pluginErrs, 0)
@ -799,7 +845,10 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
Backend: true, Backend: true,
Executable: "test", Executable: "test",
}, },
PluginDir: filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin"), FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin"), "plugin.json"): {},
filepath.Join(filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin"), "MANIFEST.txt"): {},
}, filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin")),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.PrivateSignature, SignatureType: plugins.PrivateSignature,
@ -822,8 +871,8 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
got, err := l.Load(context.Background(), plugins.External, paths) got, err := l.Load(context.Background(), plugins.External, paths)
require.NoError(t, err) require.NoError(t, err)
if !cmp.Equal(got, expected, compareOpts) { if !cmp.Equal(got, expected, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
} }
verifyState(t, expected, reg, procPrvdr, storage, procMgr) verifyState(t, expected, reg, procPrvdr, storage, procMgr)
}) })
@ -878,7 +927,7 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
}, },
Backend: false, Backend: false,
}, },
PluginDir: pluginDir, FS: plugins.NewLocalFS(filesInDir(t, pluginDir), pluginDir),
Class: plugins.External, Class: plugins.External,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -901,8 +950,8 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
got, err := l.Load(context.Background(), plugins.External, []string{pluginDir, pluginDir}) got, err := l.Load(context.Background(), plugins.External, []string{pluginDir, pluginDir})
require.NoError(t, err) require.NoError(t, err)
if !cmp.Equal(got, expected, compareOpts) { if !cmp.Equal(got, expected, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
} }
verifyState(t, expected, reg, procPrvdr, storage, procMgr) verifyState(t, expected, reg, procPrvdr, storage, procMgr)
@ -941,7 +990,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/test-datasource/module", Module: "plugins/test-datasource/module",
BaseURL: "public/plugins/test-datasource", BaseURL: "public/plugins/test-datasource",
PluginDir: filepath.Join(rootDir, "testdata/nested-plugins/parent"), FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/nested-plugins/parent")),
filepath.Join(rootDir, "testdata/nested-plugins/parent")),
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -973,7 +1023,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/test-panel/module", Module: "plugins/test-panel/module",
BaseURL: "public/plugins/test-panel", BaseURL: "public/plugins/test-panel",
PluginDir: filepath.Join(rootDir, "testdata/nested-plugins/parent/nested"), FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/nested-plugins/parent/nested")),
filepath.Join(rootDir, "testdata/nested-plugins/parent/nested")),
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs", SignatureOrg: "Grafana Labs",
@ -1004,8 +1055,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}) })
expected := []*plugins.Plugin{parent, child} expected := []*plugins.Plugin{parent, child}
if !cmp.Equal(got, expected, compareOpts) { if !cmp.Equal(got, expected, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
} }
verifyState(t, expected, reg, procPrvdr, storage, procMgr) verifyState(t, expected, reg, procPrvdr, storage, procMgr)
@ -1019,8 +1070,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
return got[i].ID < got[j].ID return got[i].ID < got[j].ID
}) })
if !cmp.Equal(got, []*plugins.Plugin{}, compareOpts) { if !cmp.Equal(got, []*plugins.Plugin{}, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
} }
verifyState(t, expected, reg, procPrvdr, storage, procMgr) verifyState(t, expected, reg, procPrvdr, storage, procMgr)
@ -1100,7 +1151,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/myorgid-simple-app/module", Module: "plugins/myorgid-simple-app/module",
BaseURL: "public/plugins/myorgid-simple-app", BaseURL: "public/plugins/myorgid-simple-app",
PluginDir: filepath.Join(rootDir, "testdata/app-with-child/dist"), FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/app-with-child/dist")),
filepath.Join(rootDir, "testdata/app-with-child/dist")),
DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react", DefaultNavURL: "/plugins/myorgid-simple-app/page/root-page-react",
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -1138,7 +1190,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
}, },
Module: "plugins/myorgid-simple-app/child/module", Module: "plugins/myorgid-simple-app/child/module",
BaseURL: "public/plugins/myorgid-simple-app", BaseURL: "public/plugins/myorgid-simple-app",
PluginDir: filepath.Join(rootDir, "testdata/app-with-child/dist/child"), FS: plugins.NewLocalFS(filesInDir(t, filepath.Join(rootDir, "testdata/app-with-child/dist/child")),
filepath.Join(rootDir, "testdata/app-with-child/dist/child")),
IncludedInAppID: parent.ID, IncludedInAppID: parent.ID,
Signature: plugins.SignatureValid, Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature, SignatureType: plugins.GrafanaSignature,
@ -1168,196 +1221,18 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
return got[i].ID < got[j].ID return got[i].ID < got[j].ID
}) })
if !cmp.Equal(got, expected, compareOpts) { if !cmp.Equal(got, expected, compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts...))
}
verifyState(t, expected, reg, procPrvdr, storage, procMgr)
t.Run("order of loaded parent and child plugins gives same output", func(t *testing.T) {
parentPluginJSON := filepath.Join(rootDir, "testdata/app-with-child/dist/plugin.json")
childPluginJSON := filepath.Join(rootDir, "testdata/app-with-child/dist/child/plugin.json")
reg = fakes.NewFakePluginRegistry()
storage = fakes.NewFakePluginStorage()
procPrvdr = fakes.NewFakeBackendProcessProvider()
procMgr = fakes.NewFakeProcessManager()
l = newLoader(&config.Cfg{}, func(l *Loader) {
l.pluginRegistry = reg
l.pluginStorage = storage
l.processManager = procMgr
l.pluginInitializer = initializer.New(&config.Cfg{}, procPrvdr, fakes.NewFakeLicensingService())
})
got, err = l.loadPlugins(context.Background(), plugins.External, []string{parentPluginJSON, childPluginJSON})
require.NoError(t, err)
// to ensure we can compare with expected
sort.SliceStable(got, func(i, j int) bool {
return got[i].ID < got[j].ID
})
if !cmp.Equal(got, expected, compareOpts) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts))
}
verifyState(t, expected, reg, procPrvdr, storage, procMgr)
reg = fakes.NewFakePluginRegistry()
storage = fakes.NewFakePluginStorage()
procPrvdr = fakes.NewFakeBackendProcessProvider()
procMgr = fakes.NewFakeProcessManager()
l = newLoader(&config.Cfg{}, func(l *Loader) {
l.pluginRegistry = reg
l.pluginStorage = storage
l.processManager = procMgr
l.pluginInitializer = initializer.New(&config.Cfg{}, procPrvdr, fakes.NewFakeLicensingService())
})
got, err = l.loadPlugins(context.Background(), plugins.External, []string{childPluginJSON, parentPluginJSON})
require.NoError(t, err)
// to ensure we can compare with expected
sort.SliceStable(got, func(i, j int) bool {
return got[i].ID < got[j].ID
})
if !cmp.Equal(got, expected, compareOpts) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts))
} }
verifyState(t, expected, reg, procPrvdr, storage, procMgr) verifyState(t, expected, reg, procPrvdr, storage, procMgr)
}) })
})
}
func TestLoader_readPluginJSON(t *testing.T) {
tests := []struct {
name string
pluginPath string
expected plugins.JSONData
failed bool
}{
{
name: "Valid plugin",
pluginPath: "../testdata/test-app/plugin.json",
expected: plugins.JSONData{
ID: "test-app",
Type: "app",
Name: "Test App",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Test Inc.",
URL: "http://test.com",
},
Description: "Official Grafana Test App & Dashboard bundle",
Version: "1.0.0",
Links: []plugins.InfoLink{
{Name: "Project site", URL: "http://project.com"},
{Name: "License & Terms", URL: "http://license.com"},
},
Logos: plugins.Logos{
Small: "img/logo_small.png",
Large: "img/logo_large.png",
},
Screenshots: []plugins.Screenshots{
{Path: "img/screenshot1.png", Name: "img1"},
{Path: "img/screenshot2.png", Name: "img2"},
},
Updated: "2015-02-10",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "3.x.x",
Plugins: []plugins.Dependency{
{Type: "datasource", ID: "graphite", Name: "Graphite", Version: "1.0.0"},
{Type: "panel", ID: "graph", Name: "Graph", Version: "1.0.0"},
},
},
Includes: []*plugins.Includes{
{Name: "Nginx Connections", Path: "dashboards/connections.json", Type: "dashboard", Role: org.RoleViewer},
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: org.RoleViewer},
{Name: "Nginx Panel", Type: "panel", Role: org.RoleViewer},
{Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer},
},
Backend: false,
},
},
{
name: "Invalid plugin JSON",
pluginPath: "../testdata/invalid-plugin-json/plugin.json",
failed: true,
},
{
name: "Non-existing JSON file",
pluginPath: "nonExistingFile.json",
failed: true,
},
}
l := newLoader(nil)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := l.readPluginJSON(tt.pluginPath)
if (err != nil) && !tt.failed {
t.Errorf("readPluginJSON() error = %v, failed %v", err, tt.failed)
return
}
if !cmp.Equal(got, tt.expected, compareOpts) {
t.Errorf("Unexpected pluginJSONData: %v", cmp.Diff(got, tt.expected, compareOpts))
}
})
}
}
func Test_validatePluginJSON(t *testing.T) {
type args struct {
data plugins.JSONData
}
tests := []struct {
name string
args args
err error
}{
{
name: "Valid case",
args: args{
data: plugins.JSONData{
ID: "grafana-plugin-id",
Type: plugins.DataSource,
},
},
},
{
name: "Invalid plugin ID",
args: args{
data: plugins.JSONData{
Type: plugins.Panel,
},
},
err: ErrInvalidPluginJSON,
},
{
name: "Invalid plugin type",
args: args{
data: plugins.JSONData{
ID: "grafana-plugin-id",
Type: "test",
},
},
err: ErrInvalidPluginJSON,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validatePluginJSON(tt.args.data); !errors.Is(err, tt.err) {
t.Errorf("validatePluginJSON() = %v, want %v", err, tt.err)
}
})
}
} }
func Test_setPathsBasedOnApp(t *testing.T) { func Test_setPathsBasedOnApp(t *testing.T) {
t.Run("When setting paths based on core plugin on Windows", func(t *testing.T) { t.Run("When setting paths based on core plugin on Windows", func(t *testing.T) {
child := &plugins.Plugin{ child := &plugins.Plugin{
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata-app\\datasources\\datasource", FS: fakes.NewFakePluginFiles("c:\\grafana\\public\\app\\plugins\\app\\testdata-app\\datasources\\datasource"),
} }
parent := &plugins.Plugin{ parent := &plugins.Plugin{
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
@ -1365,7 +1240,7 @@ func Test_setPathsBasedOnApp(t *testing.T) {
ID: "testdata-app", ID: "testdata-app",
}, },
Class: plugins.Core, Class: plugins.Core,
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata-app", FS: fakes.NewFakePluginFiles("c:\\grafana\\public\\app\\plugins\\app\\testdata-app"),
BaseURL: "public/app/plugins/app/testdata-app", BaseURL: "public/app/plugins/app/testdata-app",
} }
@ -1395,8 +1270,8 @@ func verifyState(t *testing.T, ps []*plugins.Plugin, reg *fakes.FakePluginRegist
t.Helper() t.Helper()
for _, p := range ps { for _, p := range ps {
if !cmp.Equal(p, reg.Store[p.ID], compareOpts) { if !cmp.Equal(p, reg.Store[p.ID], compareOpts...) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(p, reg.Store[p.ID], compareOpts)) t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(p, reg.Store[p.ID], compareOpts...))
} }
if p.Backend { if p.Backend {
@ -1418,3 +1293,40 @@ func verifyState(t *testing.T, ps []*plugins.Plugin, reg *fakes.FakePluginRegist
require.Zero(t, procMngr.Stopped[p.ID]) require.Zero(t, procMngr.Stopped[p.ID])
} }
} }
func filesInDir(t *testing.T, dir string) map[string]struct{} {
files, err := collectFilesWithin(dir)
if err != nil {
t.Logf("Could not collect plugin file info. Err: %v", err)
return map[string]struct{}{}
}
return files
}
func collectFilesWithin(dir string) (map[string]struct{}, error) {
files := map[string]struct{}{}
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// skip directories
if info.IsDir() {
return nil
}
// verify that file is within plugin directory
//file, err := filepath.Rel(dir, path)
//if err != nil {
// return err
//}
//if strings.HasPrefix(file, ".."+string(filepath.Separator)) {
// return fmt.Errorf("file '%s' not inside of plugin directory", file)
//}
files[path] = struct{}{}
return nil
})
return files, err
}

View File

@ -4,13 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana-azure-sdk-go/azsettings" "github.com/grafana/grafana-azure-sdk-go/azsettings"
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
@ -27,10 +23,12 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/plugins/manager/client"
"github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/sources" "github.com/grafana/grafana/pkg/plugins/manager/sources"
"github.com/grafana/grafana/pkg/plugins/manager/store" "github.com/grafana/grafana/pkg/plugins/manager/store"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/searchV2"
@ -126,7 +124,7 @@ func TestIntegrationPluginManager(t *testing.T) {
ctx := context.Background() ctx := context.Background()
verifyCorePluginCatalogue(t, ctx, ps) verifyCorePluginCatalogue(t, ctx, ps)
verifyBundledPlugins(t, ctx, ps, reg) verifyBundledPlugins(t, ctx, ps)
verifyPluginStaticRoutes(t, ctx, ps, reg) verifyPluginStaticRoutes(t, ctx, ps, reg)
verifyBackendProcesses(t, reg.Plugins(ctx)) verifyBackendProcesses(t, reg.Plugins(ctx))
verifyPluginQuery(t, ctx, client.ProvideService(reg, pCfg)) verifyPluginQuery(t, ctx, client.ProvideService(reg, pCfg))
@ -255,7 +253,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
require.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(ps.Plugins(ctx))) require.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(ps.Plugins(ctx)))
} }
func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service, reg registry.Service) { func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service) {
t.Helper() t.Helper()
dsPlugins := make(map[string]struct{}) dsPlugins := make(map[string]struct{})
@ -268,9 +266,6 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service,
require.NotEqual(t, plugins.PluginDTO{}, inputPlugin) require.NotEqual(t, plugins.PluginDTO{}, inputPlugin)
require.NotNil(t, dsPlugins["input"]) require.NotNil(t, dsPlugins["input"])
intInputPlugin, exists := reg.Plugin(ctx, "input")
require.True(t, exists)
pluginRoutes := make(map[string]*plugins.StaticRoute) pluginRoutes := make(map[string]*plugins.StaticRoute)
for _, r := range ps.Routes() { for _, r := range ps.Routes() {
pluginRoutes[r.PluginID] = r pluginRoutes[r.PluginID] = r
@ -278,7 +273,7 @@ func verifyBundledPlugins(t *testing.T, ctx context.Context, ps *store.Service,
for _, pluginID := range []string{"input"} { for _, pluginID := range []string{"input"} {
require.Contains(t, pluginRoutes, pluginID) require.Contains(t, pluginRoutes, pluginID)
require.True(t, strings.HasPrefix(pluginRoutes[pluginID].Directory, intInputPlugin.PluginDir)) require.Equal(t, pluginRoutes[pluginID].Directory, inputPlugin.Base())
} }
} }
@ -292,11 +287,11 @@ func verifyPluginStaticRoutes(t *testing.T, ctx context.Context, rr plugins.Stat
inputPlugin, _ := reg.Plugin(ctx, "input") inputPlugin, _ := reg.Plugin(ctx, "input")
require.NotNil(t, routes["input"]) require.NotNil(t, routes["input"])
require.Equal(t, routes["input"].Directory, inputPlugin.PluginDir) require.Equal(t, routes["input"].Directory, inputPlugin.FS.Base())
testAppPlugin, _ := reg.Plugin(ctx, "test-app") testAppPlugin, _ := reg.Plugin(ctx, "test-app")
require.Contains(t, routes, "test-app") require.Contains(t, routes, "test-app")
require.Equal(t, routes["test-app"].Directory, testAppPlugin.PluginDir) require.Equal(t, routes["test-app"].Directory, testAppPlugin.FS.Base())
} }
func verifyBackendProcesses(t *testing.T, ps []*plugins.Plugin) { func verifyBackendProcesses(t *testing.T, ps []*plugins.Plugin) {

View File

@ -11,7 +11,6 @@ import (
"net/url" "net/url"
"os" "os"
"path" "path"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
@ -57,8 +56,8 @@ N1c5v9v/4h6qeA==
var runningWindows = runtime.GOOS == "windows" var runningWindows = runtime.GOOS == "windows"
// pluginManifest holds details for the file manifest // PluginManifest holds details for the file manifest
type pluginManifest struct { type PluginManifest struct {
Plugin string `json:"plugin"` Plugin string `json:"plugin"`
Version string `json:"version"` Version string `json:"version"`
KeyID string `json:"keyId"` KeyID string `json:"keyId"`
@ -73,20 +72,20 @@ type pluginManifest struct {
RootURLs []string `json:"rootUrls"` RootURLs []string `json:"rootUrls"`
} }
func (m *pluginManifest) isV2() bool { func (m *PluginManifest) isV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.") return strings.HasPrefix(m.ManifestVersion, "2.")
} }
// readPluginManifest attempts to read and verify the plugin manifest // readPluginManifest attempts to read and verify the plugin manifest
// if any error occurs or the manifest is not valid, this will return an error // if any error occurs or the manifest is not valid, this will return an error
func readPluginManifest(body []byte) (*pluginManifest, error) { func ReadPluginManifest(body []byte) (*PluginManifest, error) {
block, _ := clearsign.Decode(body) block, _ := clearsign.Decode(body)
if block == nil { if block == nil {
return nil, errors.New("unable to decode manifest") return nil, errors.New("unable to decode manifest")
} }
// Convert to a well typed object // Convert to a well typed object
var manifest pluginManifest var manifest PluginManifest
err := json.Unmarshal(block.Plaintext, &manifest) err := json.Unmarshal(block.Plaintext, &manifest)
if err != nil { if err != nil {
return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err) return nil, fmt.Errorf("%v: %w", "Error parsing manifest JSON", err)
@ -99,32 +98,54 @@ func readPluginManifest(body []byte) (*pluginManifest, error) {
return &manifest, nil return &manifest, nil
} }
func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, error) { func Calculate(mlog log.Logger, class plugins.Class, plugin plugins.FoundPlugin) (plugins.Signature, error) {
if plugin.IsCorePlugin() { if class == plugins.Core {
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureInternal, Status: plugins.SignatureInternal,
}, nil }, nil
} }
pluginFiles, err := pluginFilesRequiringVerification(plugin) if len(plugin.FS.Files()) == 0 {
if err != nil { mlog.Warn("No plugin file information in directory", "pluginID", plugin.JSONData.ID)
mlog.Warn("Could not collect plugin file information in directory", "pluginID", plugin.ID, "dir", plugin.PluginDir)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureInvalid, Status: plugins.SignatureInvalid,
}, err }, nil
} }
byteValue := plugin.Manifest() f, err := plugin.FS.Open("MANIFEST.txt")
if err != nil || len(byteValue) < 10 { if err != nil {
mlog.Debug("Plugin is unsigned", "id", plugin.ID) if errors.Is(err, plugins.ErrFileNotExist) {
mlog.Debug("Could not find a MANIFEST.txt", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureUnsigned, Status: plugins.SignatureUnsigned,
}, nil }, nil
} }
manifest, err := readPluginManifest(byteValue) mlog.Debug("Could not open MANIFEST.txt", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureInvalid,
}, nil
}
defer func() {
if f == nil {
return
}
if err = f.Close(); err != nil {
mlog.Warn("Failed to close plugin MANIFEST file", "err", err)
}
}()
byteValue, err := io.ReadAll(f)
if err != nil || len(byteValue) < 10 {
mlog.Debug("MANIFEST.TXT is invalid", "id", plugin.JSONData.ID)
return plugins.Signature{
Status: plugins.SignatureUnsigned,
}, nil
}
manifest, err := ReadPluginManifest(byteValue)
if err != nil { if err != nil {
mlog.Debug("Plugin signature invalid", "id", plugin.ID, "err", err) mlog.Debug("Plugin signature invalid", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureInvalid, Status: plugins.SignatureInvalid,
}, nil }, nil
@ -137,7 +158,7 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
} }
// Make sure the versions all match // Make sure the versions all match
if manifest.Plugin != plugin.ID || manifest.Version != plugin.Info.Version { if manifest.Plugin != plugin.JSONData.ID || manifest.Version != plugin.JSONData.Info.Version {
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureModified, Status: plugins.SignatureModified,
}, nil }, nil
@ -146,10 +167,10 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
// Validate that plugin is running within defined root URLs // Validate that plugin is running within defined root URLs
if len(manifest.RootURLs) > 0 { if len(manifest.RootURLs) > 0 {
if match, err := urlMatch(manifest.RootURLs, setting.AppUrl, manifest.SignatureType); err != nil { if match, err := urlMatch(manifest.RootURLs, setting.AppUrl, manifest.SignatureType); err != nil {
mlog.Warn("Could not verify if root URLs match", "plugin", plugin.ID, "rootUrls", manifest.RootURLs) mlog.Warn("Could not verify if root URLs match", "plugin", plugin.JSONData.ID, "rootUrls", manifest.RootURLs)
return plugins.Signature{}, err return plugins.Signature{}, err
} else if !match { } else if !match {
mlog.Warn("Could not find root URL that matches running application URL", "plugin", plugin.ID, mlog.Warn("Could not find root URL that matches running application URL", "plugin", plugin.JSONData.ID,
"appUrl", setting.AppUrl, "rootUrls", manifest.RootURLs) "appUrl", setting.AppUrl, "rootUrls", manifest.RootURLs)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureInvalid, Status: plugins.SignatureInvalid,
@ -161,7 +182,7 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
// Verify the manifest contents // Verify the manifest contents
for p, hash := range manifest.Files { for p, hash := range manifest.Files {
err = verifyHash(mlog, plugin.ID, filepath.Join(plugin.PluginDir, p), hash) err = verifyHash(mlog, plugin, p, hash)
if err != nil { if err != nil {
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureModified, Status: plugins.SignatureModified,
@ -173,20 +194,28 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
// Track files missing from the manifest // Track files missing from the manifest
var unsignedFiles []string var unsignedFiles []string
for _, f := range pluginFiles { for _, f := range plugin.FS.Files() {
// Ignoring unsigned Chromium debug.log so it doesn't invalidate the signature for Renderer plugin running on Windows
if runningWindows && plugin.JSONData.Type == plugins.Renderer && f == "chrome-win/debug.log" {
continue
}
if f == "MANIFEST.txt" {
continue
}
if _, exists := manifestFiles[f]; !exists { if _, exists := manifestFiles[f]; !exists {
unsignedFiles = append(unsignedFiles, f) unsignedFiles = append(unsignedFiles, f)
} }
} }
if len(unsignedFiles) > 0 { if len(unsignedFiles) > 0 {
mlog.Warn("The following files were not included in the signature", "plugin", plugin.ID, "files", unsignedFiles) mlog.Warn("The following files were not included in the signature", "plugin", plugin.JSONData.ID, "files", unsignedFiles)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureModified, Status: plugins.SignatureModified,
}, nil }, nil
} }
mlog.Debug("Plugin signature valid", "id", plugin.ID) mlog.Debug("Plugin signature valid", "id", plugin.JSONData.ID)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureValid, Status: plugins.SignatureValid,
Type: manifest.SignatureType, Type: manifest.SignatureType,
@ -194,17 +223,17 @@ func Calculate(mlog log.Logger, plugin *plugins.Plugin) (plugins.Signature, erro
}, nil }, nil
} }
func verifyHash(mlog log.Logger, pluginID string, path string, hash string) error { func verifyHash(mlog log.Logger, plugin plugins.FoundPlugin, path, hash string) error {
// nolint:gosec // nolint:gosec
// We can ignore the gosec G304 warning on this one because `path` is based // We can ignore the gosec G304 warning on this one because `path` is based
// on the path provided in a manifest file for a plugin and not user input. // on the path provided in a manifest file for a plugin and not user input.
f, err := os.Open(path) f, err := plugin.FS.Open(path)
if err != nil { if err != nil {
if os.IsPermission(err) { if os.IsPermission(err) {
mlog.Warn("Could not open plugin file due to lack of permissions", "plugin", pluginID, "path", path) mlog.Warn("Could not open plugin file due to lack of permissions", "plugin", plugin.JSONData.ID, "path", path)
return errors.New("permission denied when attempting to read plugin file") return errors.New("permission denied when attempting to read plugin file")
} }
mlog.Warn("Plugin file listed in the manifest was not found", "plugin", pluginID, "path", path) mlog.Warn("Plugin file listed in the manifest was not found", "plugin", plugin.JSONData.ID, "path", path)
return errors.New("plugin file listed in the manifest was not found") return errors.New("plugin file listed in the manifest was not found")
} }
defer func() { defer func() {
@ -219,75 +248,13 @@ func verifyHash(mlog log.Logger, pluginID string, path string, hash string) erro
} }
sum := hex.EncodeToString(h.Sum(nil)) sum := hex.EncodeToString(h.Sum(nil))
if sum != hash { if sum != hash {
mlog.Warn("Plugin file checksum does not match signature checksum", "plugin", pluginID, "path", path) mlog.Warn("Plugin file checksum does not match signature checksum", "plugin", plugin.JSONData.ID, "path", path)
return errors.New("plugin file checksum does not match signature checksum") return errors.New("plugin file checksum does not match signature checksum")
} }
return nil return nil
} }
// pluginFilesRequiringVerification gets plugin filenames that require verification for plugin signing
// returns filenames as a slice of posix style paths relative to plugin directory
func pluginFilesRequiringVerification(plugin *plugins.Plugin) ([]string, error) {
var files []string
err := filepath.Walk(plugin.PluginDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink == os.ModeSymlink {
symlinkPath, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
symlink, err := os.Stat(symlinkPath)
if err != nil {
return err
}
// verify that symlinked file is within plugin directory
p, err := filepath.Rel(plugin.PluginDir, symlinkPath)
if err != nil {
return err
}
if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) {
return fmt.Errorf("file '%s' not inside of plugin directory", p)
}
// skip adding symlinked directories
if symlink.IsDir() {
return nil
}
}
// skip directories and MANIFEST.txt
if info.IsDir() || info.Name() == "MANIFEST.txt" {
return nil
}
// Ignoring unsigned Chromium debug.log so it doesn't invalidate the signature for Renderer plugin running on Windows
if runningWindows && plugin.IsRenderer() && strings.HasSuffix(path, filepath.Join("chrome-win", "debug.log")) {
return nil
}
// verify that file is within plugin directory
file, err := filepath.Rel(plugin.PluginDir, path)
if err != nil {
return err
}
if strings.HasPrefix(file, ".."+string(filepath.Separator)) {
return fmt.Errorf("file '%s' not inside of plugin directory", file)
}
files = append(files, filepath.ToSlash(file))
return nil
})
return files, err
}
func urlMatch(specs []string, target string, signatureType plugins.SignatureType) (bool, error) { func urlMatch(specs []string, target string, signatureType plugins.SignatureType) (bool, error) {
targetURL, err := url.Parse(target) targetURL, err := url.Parse(target)
if err != nil { if err != nil {
@ -328,7 +295,7 @@ func (r invalidFieldErr) Error() string {
return fmt.Sprintf("valid manifest field %s is required", r.field) return fmt.Sprintf("valid manifest field %s is required", r.field)
} }
func validateManifest(m pluginManifest, block *clearsign.Block) error { func validateManifest(m PluginManifest, block *clearsign.Block) error {
if len(m.Plugin) == 0 { if len(m.Plugin) == 0 {
return invalidFieldErr{field: "plugin"} return invalidFieldErr{field: "plugin"}
} }

View File

@ -46,7 +46,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
-----END PGP SIGNATURE-----` -----END PGP SIGNATURE-----`
t.Run("valid manifest", func(t *testing.T) { t.Run("valid manifest", func(t *testing.T) {
manifest, err := readPluginManifest([]byte(txt)) manifest, err := ReadPluginManifest([]byte(txt))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, manifest) require.NotNil(t, manifest)
@ -62,7 +62,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
t.Run("invalid manifest", func(t *testing.T) { t.Run("invalid manifest", func(t *testing.T) {
modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx") modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx")
_, err := readPluginManifest([]byte(modified)) _, err := ReadPluginManifest([]byte(modified))
require.Error(t, err) require.Error(t, err)
}) })
} }
@ -99,7 +99,7 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
-----END PGP SIGNATURE-----` -----END PGP SIGNATURE-----`
t.Run("valid manifest", func(t *testing.T) { t.Run("valid manifest", func(t *testing.T) {
manifest, err := readPluginManifest([]byte(txt)) manifest, err := ReadPluginManifest([]byte(txt))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, manifest) require.NotNil(t, manifest)
@ -151,15 +151,18 @@ func TestCalculate(t *testing.T) {
}) })
setting.AppUrl = tc.appURL setting.AppUrl = tc.appURL
sig, err := Calculate(log.NewTestLogger(), &plugins.Plugin{ basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
sig, err := Calculate(log.NewTestLogger(), plugins.External, plugins.FoundPlugin{
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-datasource", ID: "test-datasource",
Info: plugins.Info{ Info: plugins.Info{
Version: "1.0.0", Version: "1.0.0",
}, },
}, },
PluginDir: filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin"), FS: plugins.NewLocalFS(map[string]struct{}{
Class: plugins.External, filepath.Join(basePath, "MANIFEST.txt"): {},
filepath.Join(basePath, "plugin.json"): {},
}, basePath),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, tc.expectedSignature, sig) require.Equal(t, tc.expectedSignature, sig)
@ -172,8 +175,10 @@ func TestCalculate(t *testing.T) {
runningWindows = backup runningWindows = backup
}) })
basePath := "../testdata/renderer-added-file/plugin"
runningWindows = true runningWindows = true
sig, err := Calculate(log.NewTestLogger(), &plugins.Plugin{ sig, err := Calculate(log.NewTestLogger(), plugins.External, plugins.FoundPlugin{
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-renderer", ID: "test-renderer",
Type: plugins.Renderer, Type: plugins.Renderer,
@ -181,7 +186,11 @@ func TestCalculate(t *testing.T) {
Version: "1.0.0", Version: "1.0.0",
}, },
}, },
PluginDir: "../testdata/renderer-added-file/plugin", FS: plugins.NewLocalFS(map[string]struct{}{
filepath.Join(basePath, "MANIFEST.txt"): {},
filepath.Join(basePath, "plugin.json"): {},
filepath.Join(basePath, "chrome-win/debug.log"): {},
}, basePath),
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, plugins.Signature{ require.Equal(t, plugins.Signature{
@ -192,7 +201,7 @@ func TestCalculate(t *testing.T) {
}) })
} }
func fileList(manifest *pluginManifest) []string { func fileList(manifest *PluginManifest) []string {
var keys []string var keys []string
for k := range manifest.Files { for k := range manifest.Files {
keys = append(keys, k) keys = append(keys, k)
@ -476,52 +485,52 @@ func Test_urlMatch_private(t *testing.T) {
func Test_validateManifest(t *testing.T) { func Test_validateManifest(t *testing.T) {
tcs := []struct { tcs := []struct {
name string name string
manifest *pluginManifest manifest *PluginManifest
expectedErr string expectedErr string
}{ }{
{ {
name: "Empty plugin field", name: "Empty plugin field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Plugin = "" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.Plugin = "" }),
expectedErr: "valid manifest field plugin is required", expectedErr: "valid manifest field plugin is required",
}, },
{ {
name: "Empty keyId field", name: "Empty keyId field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.KeyID = "" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.KeyID = "" }),
expectedErr: "valid manifest field keyId is required", expectedErr: "valid manifest field keyId is required",
}, },
{ {
name: "Empty signedByOrg field", name: "Empty signedByOrg field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignedByOrg = "" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrg = "" }),
expectedErr: "valid manifest field signedByOrg is required", expectedErr: "valid manifest field signedByOrg is required",
}, },
{ {
name: "Empty signedByOrgName field", name: "Empty signedByOrgName field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignedByOrgName = "" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignedByOrgName = "" }),
expectedErr: "valid manifest field SignedByOrgName is required", expectedErr: "valid manifest field SignedByOrgName is required",
}, },
{ {
name: "Empty signatureType field", name: "Empty signatureType field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignatureType = "" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "" }),
expectedErr: "valid manifest field signatureType is required", expectedErr: "valid manifest field signatureType is required",
}, },
{ {
name: "Invalid signatureType field", name: "Invalid signatureType field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.SignatureType = "invalidSignatureType" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.SignatureType = "invalidSignatureType" }),
expectedErr: "valid manifest field signatureType is required", expectedErr: "valid manifest field signatureType is required",
}, },
{ {
name: "Empty files field", name: "Empty files field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Files = map[string]string{} }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.Files = map[string]string{} }),
expectedErr: "valid manifest field files is required", expectedErr: "valid manifest field files is required",
}, },
{ {
name: "Empty time field", name: "Empty time field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Time = 0 }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.Time = 0 }),
expectedErr: "valid manifest field time is required", expectedErr: "valid manifest field time is required",
}, },
{ {
name: "Empty version field", name: "Empty version field",
manifest: createV2Manifest(t, func(m *pluginManifest) { m.Version = "" }), manifest: createV2Manifest(t, func(m *PluginManifest) { m.Version = "" }),
expectedErr: "valid manifest field version is required", expectedErr: "valid manifest field version is required",
}, },
} }
@ -533,10 +542,10 @@ func Test_validateManifest(t *testing.T) {
} }
} }
func createV2Manifest(t *testing.T, cbs ...func(*pluginManifest)) *pluginManifest { func createV2Manifest(t *testing.T, cbs ...func(*PluginManifest)) *PluginManifest {
t.Helper() t.Helper()
m := &pluginManifest{ m := &PluginManifest{
Plugin: "grafana-test-app", Plugin: "grafana-test-app",
Version: "2.5.3", Version: "2.5.3",
KeyID: "7e4d0c6a708866e7", KeyID: "7e4d0c6a708866e7",

View File

@ -4,9 +4,9 @@ import (
"context" "context"
"path/filepath" "path/filepath"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )

View File

@ -18,8 +18,9 @@ type Service struct {
func ProvideService(pluginRegistry registry.Service, pluginSources sources.Resolver, func ProvideService(pluginRegistry registry.Service, pluginSources sources.Resolver,
pluginLoader loader.Service) (*Service, error) { pluginLoader loader.Service) (*Service, error) {
for _, ps := range pluginSources.List(context.Background()) { ctx := context.Background()
if _, err := pluginLoader.Load(context.Background(), ps.Class, ps.Paths); err != nil { for _, ps := range pluginSources.List(ctx) {
if _, err := pluginLoader.Load(ctx, ps.Class, ps.Paths); err != nil {
return nil, err return nil, err
} }
} }

View File

@ -97,11 +97,11 @@ func TestStore_Plugins(t *testing.T) {
func TestStore_Routes(t *testing.T) { func TestStore_Routes(t *testing.T) {
t.Run("Routes returns all static routes for non-decommissioned plugins", func(t *testing.T) { t.Run("Routes returns all static routes for non-decommissioned plugins", func(t *testing.T) {
p1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "a-test-renderer", Type: plugins.Renderer}, PluginDir: "/some/dir"} p1 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "a-test-renderer", Type: plugins.Renderer}, FS: fakes.NewFakePluginFiles("/some/dir")}
p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "b-test-panel", Type: plugins.Panel}, PluginDir: "/grafana/"} p2 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "b-test-panel", Type: plugins.Panel}, FS: fakes.NewFakePluginFiles("/grafana/")}
p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "c-test-secrets", Type: plugins.SecretsManager}, PluginDir: "./secrets", Class: plugins.Core} p3 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "c-test-secrets", Type: plugins.SecretsManager}, FS: fakes.NewFakePluginFiles("./secrets"), Class: plugins.Core}
p4 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "d-test-datasource", Type: plugins.DataSource}, PluginDir: "../test"} p4 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "d-test-datasource", Type: plugins.DataSource}, FS: fakes.NewFakePluginFiles("../test")}
p5 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "e-test-app", Type: plugins.App}} p5 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "e-test-app", Type: plugins.App}, FS: fakes.NewFakePluginFiles("any/path")}
p6 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "f-test-app", Type: plugins.App}} p6 := &plugins.Plugin{JSONData: plugins.JSONData{ID: "f-test-app", Type: plugins.App}}
p6.RegisterClient(&DecommissionedPlugin{}) p6.RegisterClient(&DecommissionedPlugin{})
@ -115,7 +115,7 @@ func TestStore_Routes(t *testing.T) {
})) }))
sr := func(p *plugins.Plugin) *plugins.StaticRoute { sr := func(p *plugins.Plugin) *plugins.StaticRoute {
return &plugins.StaticRoute{PluginID: p.ID, Directory: p.PluginDir} return &plugins.StaticRoute{PluginID: p.ID, Directory: p.FS.Base()}
} }
rs := ps.Routes() rs := ps.Routes()

View File

@ -6,8 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "path"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
@ -26,7 +25,7 @@ var ErrFileNotExist = errors.New("file does not exist")
type Plugin struct { type Plugin struct {
JSONData JSONData
PluginDir string FS FS
Class Class Class Class
// App fields // App fields
@ -55,8 +54,8 @@ type Plugin struct {
type PluginDTO struct { type PluginDTO struct {
JSONData JSONData
fs FS
logger log.Logger logger log.Logger
pluginDir string
supportsStreaming bool supportsStreaming bool
Class Class Class Class
@ -81,6 +80,10 @@ func (p PluginDTO) SupportsStreaming() bool {
return p.supportsStreaming return p.supportsStreaming
} }
func (p PluginDTO) Base() string {
return p.fs.Base()
}
func (p PluginDTO) IsApp() bool { func (p PluginDTO) IsApp() bool {
return p.Type == App return p.Type == App
} }
@ -96,21 +99,15 @@ func (p PluginDTO) File(name string) (fs.File, error) {
return nil, err return nil, err
} }
absPluginDir, err := filepath.Abs(p.pluginDir) if p.fs == nil {
return nil, ErrFileNotExist
}
f, err := p.fs.Open(cleanPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
absFilePath := filepath.Join(absPluginDir, cleanPath)
// Wrapping in filepath.Clean to properly handle
// gosec G304 Potential file inclusion via variable rule.
f, err := os.Open(filepath.Clean(absFilePath))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrFileNotExist
}
return nil, err
}
return f, nil return f, nil
} }
@ -337,6 +334,18 @@ func (p *Plugin) Client() (PluginClient, bool) {
} }
func (p *Plugin) ExecutablePath() string { func (p *Plugin) ExecutablePath() string {
if p.IsRenderer() {
return p.executablePath("plugin_start")
}
if p.IsSecretsManager() {
return p.executablePath("secrets_plugin_start")
}
return p.executablePath(p.Executable)
}
func (p *Plugin) executablePath(f string) string {
os := strings.ToLower(runtime.GOOS) os := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH arch := runtime.GOARCH
extension := "" extension := ""
@ -344,15 +353,7 @@ func (p *Plugin) ExecutablePath() string {
if os == "windows" { if os == "windows" {
extension = ".exe" extension = ".exe"
} }
if p.IsRenderer() { return path.Join(p.FS.Base(), fmt.Sprintf("%s_%s_%s%s", f, os, strings.ToLower(arch), extension))
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension))
}
if p.IsSecretsManager() {
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", "secrets_plugin_start", os, strings.ToLower(arch), extension))
}
return filepath.Join(p.PluginDir, fmt.Sprintf("%s_%s_%s%s", p.Executable, os, strings.ToLower(arch), extension))
} }
type PluginClient interface { type PluginClient interface {
@ -366,9 +367,10 @@ type PluginClient interface {
func (p *Plugin) ToDTO() PluginDTO { func (p *Plugin) ToDTO() PluginDTO {
return PluginDTO{ return PluginDTO{
logger: p.Logger(), logger: p.Logger(),
pluginDir: p.PluginDir, fs: p.FS,
JSONData: p.JSONData, supportsStreaming: p.client != nil && p.client.(backend.StreamHandler) != nil,
Class: p.Class, Class: p.Class,
JSONData: p.JSONData,
IncludedInAppID: p.IncludedInAppID, IncludedInAppID: p.IncludedInAppID,
DefaultNavURL: p.DefaultNavURL, DefaultNavURL: p.DefaultNavURL,
Pinned: p.Pinned, Pinned: p.Pinned,
@ -378,7 +380,6 @@ func (p *Plugin) ToDTO() PluginDTO {
SignatureError: p.SignatureError, SignatureError: p.SignatureError,
Module: p.Module, Module: p.Module,
BaseURL: p.BaseURL, BaseURL: p.BaseURL,
supportsStreaming: p.client != nil && p.client.(backend.StreamHandler) != nil,
} }
} }
@ -387,7 +388,11 @@ func (p *Plugin) StaticRoute() *StaticRoute {
return nil return nil
} }
return &StaticRoute{Directory: p.PluginDir, PluginID: p.ID} if p.FS == nil {
return nil
}
return &StaticRoute{Directory: p.FS.Base(), PluginID: p.ID}
} }
func (p *Plugin) IsRenderer() bool { func (p *Plugin) IsRenderer() bool {
@ -414,15 +419,6 @@ func (p *Plugin) IsExternalPlugin() bool {
return p.Class == External return p.Class == External
} }
func (p *Plugin) Manifest() []byte {
d, err := os.ReadFile(filepath.Join(p.PluginDir, "MANIFEST.txt"))
if err != nil {
return []byte{}
}
return d
}
type Class string type Class string
const ( const (

View File

@ -135,6 +135,7 @@ func TestPluginUpdateChecker_checkForUpdates(t *testing.T) {
Info: plugins.Info{Version: "0.9.0"}, Info: plugins.Info{Version: "0.9.0"},
Type: plugins.DataSource, Type: plugins.DataSource,
}, },
Class: plugins.External,
}, },
{ {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
@ -142,6 +143,7 @@ func TestPluginUpdateChecker_checkForUpdates(t *testing.T) {
Info: plugins.Info{Version: "0.5.0"}, Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App, Type: plugins.App,
}, },
Class: plugins.External,
}, },
{ {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
@ -149,14 +151,15 @@ func TestPluginUpdateChecker_checkForUpdates(t *testing.T) {
Info: plugins.Info{Version: "2.5.7"}, Info: plugins.Info{Version: "2.5.7"},
Type: plugins.Panel, Type: plugins.Panel,
}, },
Class: plugins.Bundled,
}, },
{ {
Class: plugins.Core,
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-core-panel", ID: "test-core-panel",
Info: plugins.Info{Version: "0.0.1"}, Info: plugins.Info{Version: "0.0.1"},
Type: plugins.Panel, Type: plugins.Panel,
}, },
Class: plugins.Core,
}, },
}, },
}, },