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:
@@ -1,90 +1,44 @@
|
||||
package finder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"context"
|
||||
|
||||
"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/util"
|
||||
)
|
||||
|
||||
var walk = util.Walk
|
||||
|
||||
type Finder struct {
|
||||
log log.Logger
|
||||
type Service struct {
|
||||
local *FS
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
func New() Finder {
|
||||
return Finder{log: log.New("plugin.finder")}
|
||||
func NewService() *Service {
|
||||
logger := log.New("plugin.finder")
|
||||
return &Service{
|
||||
local: newFS(logger),
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Finder) Find(pluginPaths []string) ([]string, error) {
|
||||
var pluginJSONPaths []string
|
||||
func (f *Service) Find(ctx context.Context, pluginPaths ...string) ([]*plugins.FoundBundle, error) {
|
||||
if len(pluginPaths) == 0 {
|
||||
return []*plugins.FoundBundle{}, nil
|
||||
}
|
||||
|
||||
fbs := make(map[string][]*plugins.FoundBundle)
|
||||
for _, path := range pluginPaths {
|
||||
exists, err := fs.Exists(path)
|
||||
local, err := f.local.Find(ctx, path)
|
||||
if err != nil {
|
||||
f.log.Warn("Error occurred when checking if plugin directory exists", "path", path, "err", err)
|
||||
}
|
||||
if !exists {
|
||||
f.log.Warn("Skipping finding plugins as directory does not exist", "path", path)
|
||||
f.log.Warn("Error occurred when trying to find plugin", "path", path)
|
||||
continue
|
||||
}
|
||||
|
||||
paths, err := f.getAbsPluginJSONPaths(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pluginJSONPaths = append(pluginJSONPaths, paths...)
|
||||
fbs[path] = local
|
||||
}
|
||||
|
||||
return pluginJSONPaths, nil
|
||||
}
|
||||
|
||||
func (f *Finder) 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
|
||||
var found []*plugins.FoundBundle
|
||||
for _, fb := range fbs {
|
||||
found = append(found, fb...)
|
||||
}
|
||||
|
||||
return pluginJSONPaths, nil
|
||||
return found, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user