Plugins: Refactor Plugin Management (#40477)

* add core plugin flow

* add instrumentation

* move func

* remove cruft

* support external backend plugins

* refactor + clean up

* remove comments

* refactor loader

* simplify core plugin path arg

* cleanup loggers

* move signature validator to plugins package

* fix sig packaging

* cleanup plugin model

* remove unnecessary plugin field

* add start+stop for pm

* fix failures

* add decommissioned state

* export fields just to get things flowing

* fix comments

* set static routes

* make image loading idempotent

* merge with backend plugin manager

* re-use funcs

* reorder imports + remove unnecessary interface

* add some TODOs + remove unused func

* remove unused instrumentation func

* simplify client usage

* remove import alias

* re-use backendplugin.Plugin interface

* re order funcs

* improve var name

* fix log statements

* refactor data model

* add logic for dupe check during loading

* cleanup state setting

* refactor loader

* cleanup manager interface

* add rendering flow

* refactor loading + init

* add renderer support

* fix renderer plugin

* reformat imports

* track errors

* fix plugin signature inheritance

* name param in interface

* update func comment

* fix func arg name

* introduce class concept

* remove func

* fix external plugin check

* apply changes from pm-experiment

* fix core plugins

* fix imports

* rename interface

* comment API interface

* add support for testdata plugin

* enable alerting + use correct core plugin contracts

* slim manager API

* fix param name

* fix filter

* support static routes

* fix rendering

* tidy rendering

* get tests compiling

* fix install+uninstall

* start finder test

* add finder test coverage

* start loader tests

* add test for core plugins

* load core + bundled test

* add test for nested plugin loading

* add test files

* clean interface + fix registering some core plugins

* refactoring

* reformat and create sub packages

* simplify core plugin init

* fix ctx cancel scenario

* migrate initializer

* remove Init() funcs

* add test starter

* new logger

* flesh out initializer tests

* refactoring

* remove unused svc

* refactor rendering flow

* fixup loader tests

* add enabled helper func

* fix logger name

* fix data fetchers

* fix case where plugin dir doesn't exist

* improve coverage + move dupe checking to loader

* remove noisy debug logs

* register core plugins automagically

* add support for renderer in catalog

* make private func + fix req validation

* use interface

* re-add check for renderer in catalog

* tidy up from moving to auto reg core plugins

* core plugin registrar

* guards

* copy over core plugins for test infra

* all tests green

* renames

* propagate new interfaces

* kill old manager

* get compiling

* tidy up

* update naming

* refactor manager test + cleanup

* add more cases to finder test

* migrate validator to field

* more coverage

* refactor dupe checking

* add test for plugin class

* add coverage for initializer

* split out rendering

* move

* fixup tests

* fix uss test

* fix frontend settings

* fix grafanads test

* add check when checking sig errors

* fix enabled map

* fixup

* allow manual setup of CM

* rename to cloud-monitoring

* remove TODO

* add installer interface for testing

* loader interface returns

* tests passing

* refactor + add more coverage

* support 'stackdriver'

* fix frontend settings loading

* improve naming based on package name

* small tidy

* refactor test

* fix renderer start

* make cloud-monitoring plugin ID clearer

* add plugin update test

* add integration tests

* don't break all if sig can't be calculated

* add root URL check test

* add more signature verification tests

* update DTO name

* update enabled plugins comment

* update comments

* fix linter

* revert fe naming change

* fix errors endpoint

* reset error code field name

* re-order test to help verify

* assert -> require

* pm check

* add missing entry + re-order

* re-check

* dump icon log

* verify manager contents first

* reformat

* apply PR feedback

* apply style changes

* fix one vs all loading err

* improve log output

* only start when no signature error

* move log

* rework plugin update check

* fix test

* fix multi loading from cfg.PluginSettings

* improve log output #2

* add error abstraction to capture errors without registering a plugin

* add debug log

* add unsigned warning

* e2e test attempt

* fix logger

* set home path

* prevent panic

* alternate

* ugh.. fix home path

* return renderer even if not started

* make renderer plugin managed

* add fallback renderer icon, update renderer badge + prevent changes when renderer is installed

* fix icon loading

* rollback renderer changes

* use correct field

* remove unneccessary block

* remove newline

* remove unused func

* fix bundled plugins base + module fields

* remove unused field since refactor

* add authorizer abstraction

* loader only returns plugins expected to run

* fix multi log output
This commit is contained in:
Will Browne
2021-11-01 10:53:33 +01:00
committed by GitHub
parent f4282571c7
commit b80fbe03f0
136 changed files with 7135 additions and 4481 deletions
@@ -0,0 +1,91 @@
package finder
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
var logger = log.New("plugin.finder")
type Finder struct {
cfg *setting.Cfg
}
func New(cfg *setting.Cfg) Finder {
return Finder{cfg: cfg}
}
func (f *Finder) Find(pluginDirs []string) ([]string, error) {
var pluginJSONPaths []string
for _, dir := range pluginDirs {
exists, err := fs.Exists(dir)
if err != nil {
log.Warn("Error occurred when checking if plugin directory exists", "dir", dir, "err", err)
}
if !exists {
logger.Warn("Skipping finding plugins as directory does not exist", "dir", dir)
continue
}
paths, err := f.getPluginJSONPaths(dir)
if err != nil {
return nil, err
}
pluginJSONPaths = append(pluginJSONPaths, paths...)
}
return pluginJSONPaths, nil
}
func (f *Finder) getPluginJSONPaths(dir string) ([]string, error) {
var pluginJSONPaths []string
var err error
dir, err = filepath.Abs(dir)
if err != nil {
return []string{}, err
}
if err := util.Walk(dir, true, true,
func(currentPath string, fi os.FileInfo, err error) error {
if err != 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 {
if errors.Is(err, os.ErrNotExist) {
logger.Debug("Couldn't scan directory since it doesn't exist", "pluginDir", dir, "err", err)
return []string{}, nil
}
if errors.Is(err, os.ErrPermission) {
logger.Debug("Couldn't scan directory due to lack of permissions", "pluginDir", dir, "err", err)
return []string{}, nil
}
return []string{}, err
}
return pluginJSONPaths, nil
}
@@ -0,0 +1,67 @@
package finder
import (
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/setting"
)
func TestFinder_Find(t *testing.T) {
testCases := []struct {
name string
cfg *setting.Cfg
pluginDirs []string
expectedPathSuffix []string
err error
}{
{
name: "Dir with single plugin",
cfg: setting.NewCfg(),
pluginDirs: []string{"../../testdata/valid-v2-signature"},
expectedPathSuffix: []string{"/pkg/plugins/manager/testdata/valid-v2-signature/plugin/plugin.json"},
},
{
name: "Dir with nested plugins",
cfg: setting.NewCfg(),
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",
cfg: setting.NewCfg(),
pluginDirs: []string{"../../testdata/symbolic-plugin-dirs"},
expectedPathSuffix: []string{"/pkg/plugins/manager/testdata/includes-symlinks/plugin.json"},
},
{
name: "Multiple plugin dirs",
cfg: setting.NewCfg(),
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(tc.cfg)
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]))
}
})
}
}
@@ -0,0 +1,273 @@
package initializer
import (
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"github.com/grafana/grafana-aws-sdk/pkg/awsds"
"github.com/gosimple/slug"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
var logger = log.New("plugin.initializer")
type Initializer struct {
cfg *setting.Cfg
license models.Licensing
}
func New(cfg *setting.Cfg, license models.Licensing) Initializer {
return Initializer{
cfg: cfg,
license: license,
}
}
func (i *Initializer) Initialize(p *plugins.Plugin) error {
if len(p.Dependencies.Plugins) == 0 {
p.Dependencies.Plugins = []plugins.Dependency{}
}
if p.Dependencies.GrafanaVersion == "" {
p.Dependencies.GrafanaVersion = "*"
}
for _, include := range p.Includes {
if include.Role == "" {
include.Role = models.ROLE_VIEWER
}
}
i.handleModuleDefaults(p)
p.Info.Logos.Small = pluginLogoURL(p.Type, p.Info.Logos.Small, p.BaseURL)
p.Info.Logos.Large = pluginLogoURL(p.Type, p.Info.Logos.Large, p.BaseURL)
for i := 0; i < len(p.Info.Screenshots); i++ {
p.Info.Screenshots[i].Path = evalRelativePluginURLPath(p.Info.Screenshots[i].Path, p.BaseURL, p.Type)
}
if p.IsApp() {
for _, child := range p.Children {
i.setPathsBasedOnApp(p, child)
}
// slugify pages
for _, include := range p.Includes {
if include.Slug == "" {
include.Slug = slug.Make(include.Name)
}
if include.Type == "page" && include.DefaultNav {
p.DefaultNavURL = i.cfg.AppSubURL + "/plugins/" + p.ID + "/page/" + include.Slug
}
if include.Type == "dashboard" && include.DefaultNav {
p.DefaultNavURL = i.cfg.AppSubURL + "/dashboard/db/" + include.Slug
}
}
}
pluginLog := logger.New("pluginID", p.ID)
p.SetLogger(pluginLog)
if p.Backend {
var backendFactory backendplugin.PluginFactoryFunc
if p.IsRenderer() {
cmd := plugins.ComposeRendererStartCommand()
backendFactory = grpcplugin.NewRendererPlugin(p.ID, filepath.Join(p.PluginDir, cmd),
func(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error {
p.Renderer = renderer
return nil
},
)
} else {
cmd := plugins.ComposePluginStartCommand(p.Executable)
backendFactory = grpcplugin.NewBackendPlugin(p.ID, filepath.Join(p.PluginDir, cmd))
}
if backendClient, err := backendFactory(p.ID, pluginLog, i.envVars(p)); err != nil {
return err
} else {
p.RegisterClient(backendClient)
}
}
return nil
}
func (i *Initializer) InitializeWithFactory(p *plugins.Plugin, factory backendplugin.PluginFactoryFunc) error {
err := i.Initialize(p)
if err != nil {
return err
}
if factory != nil {
var err error
f, err := factory(p.ID, log.New("pluginID", p.ID), []string{})
if err != nil {
return err
}
p.RegisterClient(f)
} else {
logger.Warn("Could not initialize core plugin process", "pluginID", p.ID)
return fmt.Errorf("could not initialize plugin %s", p.ID)
}
return nil
}
func (i *Initializer) handleModuleDefaults(p *plugins.Plugin) {
if p.IsCorePlugin() {
// Previously there was an assumption that the Core plugins directory
// should be public/app/plugins/<plugin type>/<plugin id>
// However this can be an issue if the Core plugins directory is renamed
baseDir := filepath.Base(p.PluginDir)
// use path package for the following statements because these are not file paths
p.Module = path.Join("app/plugins", string(p.Type), baseDir, "module")
p.BaseURL = path.Join("public/app/plugins", string(p.Type), baseDir)
return
}
metrics.SetPluginBuildInformation(p.ID, string(p.Type), p.Info.Version, string(p.Signature))
p.Module = path.Join("plugins", p.ID, "module")
p.BaseURL = path.Join("public/plugins", p.ID)
}
func (i *Initializer) setPathsBasedOnApp(parent *plugins.Plugin, child *plugins.Plugin) {
appSubPath := strings.ReplaceAll(strings.Replace(child.PluginDir, parent.PluginDir, "", 1), "\\", "/")
child.IncludedInAppID = parent.ID
child.BaseURL = parent.BaseURL
if parent.IsCorePlugin() {
child.Module = util.JoinURLFragments("app/plugins/app/"+parent.ID, appSubPath) + "/module"
} else {
child.Module = util.JoinURLFragments("plugins/"+parent.ID, appSubPath) + "/module"
}
}
func pluginLogoURL(pluginType plugins.Type, path, baseURL string) string {
if path == "" {
return defaultLogoPath(pluginType)
}
return evalRelativePluginURLPath(path, baseURL, pluginType)
}
func defaultLogoPath(pluginType plugins.Type) string {
return "public/img/icn-" + string(pluginType) + ".svg"
}
func evalRelativePluginURLPath(pathStr, baseURL string, pluginType plugins.Type) string {
if pathStr == "" {
return ""
}
u, _ := url.Parse(pathStr)
if u.IsAbs() {
return pathStr
}
// is set as default or has already been prefixed with base path
if pathStr == defaultLogoPath(pluginType) || strings.HasPrefix(pathStr, baseURL) {
return pathStr
}
return path.Join(baseURL, pathStr)
}
func (i *Initializer) envVars(plugin *plugins.Plugin) []string {
hostEnv := []string{
fmt.Sprintf("GF_VERSION=%s", i.cfg.BuildVersion),
}
if i.license != nil && i.license.HasLicense() {
hostEnv = append(
hostEnv,
fmt.Sprintf("GF_EDITION=%s", i.license.Edition()),
fmt.Sprintf("GF_ENTERPRISE_license_PATH=%s", i.cfg.EnterpriseLicensePath),
)
if envProvider, ok := i.license.(models.LicenseEnvironment); ok {
for k, v := range envProvider.Environment() {
hostEnv = append(hostEnv, fmt.Sprintf("%s=%s", k, v))
}
}
}
hostEnv = append(hostEnv, i.awsEnvVars()...)
hostEnv = append(hostEnv, i.azureEnvVars()...)
return getPluginSettings(plugin.ID, i.cfg).asEnvVar("GF_PLUGIN", hostEnv)
}
func (i *Initializer) awsEnvVars() []string {
var variables []string
if i.cfg.AWSAssumeRoleEnabled {
variables = append(variables, awsds.AssumeRoleEnabledEnvVarKeyName+"=true")
}
if len(i.cfg.AWSAllowedAuthProviders) > 0 {
variables = append(variables, awsds.AllowedAuthProvidersEnvVarKeyName+"="+strings.Join(i.cfg.AWSAllowedAuthProviders, ","))
}
return variables
}
func (i *Initializer) azureEnvVars() []string {
var variables []string
if i.cfg.Azure.Cloud != "" {
variables = append(variables, "AZURE_CLOUD="+i.cfg.Azure.Cloud)
}
if i.cfg.Azure.ManagedIdentityClientId != "" {
variables = append(variables, "AZURE_MANAGED_IDENTITY_CLIENT_ID="+i.cfg.Azure.ManagedIdentityClientId)
}
if i.cfg.Azure.ManagedIdentityEnabled {
variables = append(variables, "AZURE_MANAGED_IDENTITY_ENABLED=true")
}
return variables
}
type pluginSettings map[string]string
func (ps pluginSettings) asEnvVar(prefix string, hostEnv []string) []string {
var env []string
for k, v := range ps {
key := fmt.Sprintf("%s_%s", prefix, strings.ToUpper(k))
if value := os.Getenv(key); value != "" {
v = value
}
env = append(env, fmt.Sprintf("%s=%s", key, v))
}
env = append(env, hostEnv...)
return env
}
func getPluginSettings(pluginID string, cfg *setting.Cfg) pluginSettings {
ps := pluginSettings{}
for k, v := range cfg.PluginSettings[pluginID] {
if k == "path" || strings.ToLower(k) == "id" {
continue
}
ps[k] = v
}
return ps
}
@@ -0,0 +1,349 @@
package initializer
import (
"path"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/setting"
)
func TestInitializer_Initialize(t *testing.T) {
absCurPath, err := filepath.Abs(".")
assert.NoError(t, err)
t.Run("core backend datasource", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test",
Type: plugins.DataSource,
Includes: []*plugins.Includes{
{
Name: "Example dashboard",
Type: plugins.TypeDashboard,
},
},
Backend: true,
},
PluginDir: absCurPath,
Class: plugins.Core,
}
i := &Initializer{
cfg: setting.NewCfg(),
}
err := i.Initialize(p)
assert.NoError(t, err)
assert.Equal(t, "public/img/icn-datasource.svg", p.Info.Logos.Small)
assert.Equal(t, "public/img/icn-datasource.svg", p.Info.Logos.Large)
assert.Equal(t, "*", p.Dependencies.GrafanaVersion)
assert.Len(t, p.Includes, 1)
assert.Equal(t, models.ROLE_VIEWER, p.Includes[0].Role)
assert.Equal(t, filepath.Join("app/plugins/datasource", filepath.Base(p.PluginDir), "module"), p.Module)
assert.Equal(t, path.Join("public/app/plugins/datasource", filepath.Base(p.PluginDir)), p.BaseURL)
assert.NotNil(t, p.Logger())
c, exists := p.Client()
assert.True(t, exists)
assert.NotNil(t, c)
})
t.Run("renderer", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test",
Type: plugins.Renderer,
Dependencies: plugins.Dependencies{
GrafanaVersion: ">=8.x",
},
Backend: true,
},
PluginDir: absCurPath,
Class: plugins.External,
}
i := &Initializer{
cfg: setting.NewCfg(),
}
err := i.Initialize(p)
assert.NoError(t, err)
// TODO add default img to project
assert.Equal(t, "public/img/icn-renderer.svg", p.Info.Logos.Small)
assert.Equal(t, "public/img/icn-renderer.svg", p.Info.Logos.Large)
assert.Equal(t, ">=8.x", p.Dependencies.GrafanaVersion)
assert.Equal(t, "plugins/test/module", p.Module)
assert.Equal(t, "public/plugins/test", p.BaseURL)
assert.NotNil(t, p.Logger())
c, exists := p.Client()
assert.True(t, exists)
assert.NotNil(t, c)
})
t.Run("external app", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "parent-plugin",
Type: plugins.App,
Includes: []*plugins.Includes{
{
Type: "page",
DefaultNav: true,
Slug: "myCustomSlug",
},
},
},
PluginDir: absCurPath,
Class: plugins.External,
Children: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "child-plugin",
},
PluginDir: absCurPath,
},
},
}
i := &Initializer{
cfg: &setting.Cfg{
AppSubURL: "appSubURL",
},
}
err := i.Initialize(p)
assert.NoError(t, err)
assert.Equal(t, "public/img/icn-app.svg", p.Info.Logos.Small)
assert.Equal(t, "public/img/icn-app.svg", p.Info.Logos.Large)
assert.Equal(t, "*", p.Dependencies.GrafanaVersion)
assert.Len(t, p.Includes, 1)
assert.Equal(t, models.ROLE_VIEWER, p.Includes[0].Role)
assert.Equal(t, filepath.Join("plugins", p.ID, "module"), p.Module)
assert.Equal(t, "public/plugins/parent-plugin", p.BaseURL)
assert.NotNil(t, p.Logger())
c, exists := p.Client()
assert.False(t, exists)
assert.Nil(t, c)
assert.Len(t, p.Children, 1)
assert.Equal(t, p.ID, p.Children[0].IncludedInAppID)
assert.Equal(t, "public/plugins/parent-plugin", p.Children[0].BaseURL)
assert.Equal(t, "plugins/parent-plugin/module", p.Children[0].Module)
assert.Equal(t, "appSubURL/plugins/parent-plugin/page/myCustomSlug", p.DefaultNavURL)
})
}
func TestInitializer_InitializeWithFactory(t *testing.T) {
t.Run("happy path", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test-plugin",
Type: plugins.App,
Includes: []*plugins.Includes{
{
Type: "page",
DefaultNav: true,
Slug: "myCustomSlug",
},
},
},
PluginDir: "test/folder",
Class: plugins.External,
}
i := &Initializer{
cfg: &setting.Cfg{
AppSubURL: "appSubURL",
},
}
factoryInvoked := false
factory := backendplugin.PluginFactoryFunc(func(pluginID string, logger log.Logger, env []string) (backendplugin.Plugin, error) {
factoryInvoked = true
return testPlugin{}, nil
})
err := i.InitializeWithFactory(p, factory)
assert.NoError(t, err)
assert.True(t, factoryInvoked)
assert.NotNil(t, p.Logger())
client, exists := p.Client()
assert.True(t, exists)
assert.NotNil(t, client.(testPlugin))
})
t.Run("invalid factory", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test-plugin",
Type: plugins.App,
Includes: []*plugins.Includes{
{
Type: "page",
DefaultNav: true,
Slug: "myCustomSlug",
},
},
},
PluginDir: "test/folder",
Class: plugins.External,
}
i := &Initializer{
cfg: &setting.Cfg{
AppSubURL: "appSubURL",
},
}
err := i.InitializeWithFactory(p, nil)
assert.Errorf(t, err, "could not initialize plugin test-plugin")
c, exists := p.Client()
assert.False(t, exists)
assert.Nil(t, c)
})
}
func TestInitializer_envVars(t *testing.T) {
t.Run("backend datasource with license", func(t *testing.T) {
p := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test",
},
}
licensing := &testLicensingService{
edition: "test",
hasLicense: true,
}
i := &Initializer{
cfg: &setting.Cfg{
EnterpriseLicensePath: "/path/to/ent/license",
PluginSettings: map[string]map[string]string{
"test": {
"custom_env_var": "customVal",
},
},
},
license: licensing,
}
envVars := i.envVars(p)
assert.Len(t, envVars, 5)
assert.Equal(t, "GF_PLUGIN_CUSTOM_ENV_VAR=customVal", envVars[0])
assert.Equal(t, "GF_VERSION=", envVars[1])
assert.Equal(t, "GF_EDITION=test", envVars[2])
assert.Equal(t, "GF_ENTERPRISE_license_PATH=/path/to/ent/license", envVars[3])
assert.Equal(t, "GF_ENTERPRISE_LICENSE_TEXT=", envVars[4])
})
}
func TestInitializer_setPathsBasedOnApp(t *testing.T) {
t.Run("When setting paths based on core plugin on Windows", func(t *testing.T) {
i := &Initializer{
cfg: setting.NewCfg(),
}
child := &plugins.Plugin{
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata\\datasources\\datasource",
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "testdata",
},
Class: plugins.Core,
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata",
BaseURL: "public/app/plugins/app/testdata",
}
i.setPathsBasedOnApp(parent, child)
assert.Equal(t, "app/plugins/app/testdata/datasources/datasource/module", child.Module)
assert.Equal(t, "testdata", child.IncludedInAppID)
assert.Equal(t, "public/app/plugins/app/testdata", child.BaseURL)
})
}
func TestInitializer_getAWSEnvironmentVariables(t *testing.T) {
}
func TestInitializer_getAzureEnvironmentVariables(t *testing.T) {
}
func TestInitializer_handleModuleDefaults(t *testing.T) {
}
func Test_defaultLogoPath(t *testing.T) {
}
func Test_evalRelativePluginUrlPath(t *testing.T) {
}
func Test_getPluginLogoUrl(t *testing.T) {
}
func Test_getPluginSettings(t *testing.T) {
}
func Test_pluginSettings_ToEnv(t *testing.T) {
}
type testLicensingService struct {
edition string
hasLicense bool
tokenRaw string
}
func (t *testLicensingService) HasLicense() bool {
return t.hasLicense
}
func (t *testLicensingService) Expiry() int64 {
return 0
}
func (t *testLicensingService) Edition() string {
return t.edition
}
func (t *testLicensingService) StateInfo() string {
return ""
}
func (t *testLicensingService) ContentDeliveryPrefix() string {
return ""
}
func (t *testLicensingService) LicenseURL(showAdminLicensingPage bool) string {
return ""
}
func (t *testLicensingService) HasValidLicense() bool {
return false
}
func (t *testLicensingService) Environment() map[string]string {
return map[string]string{"GF_ENTERPRISE_LICENSE_TEXT": t.tokenRaw}
}
type testPlugin struct {
backendplugin.Plugin
}
+297
View File
@@ -0,0 +1,297 @@
package loader
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/grafana/grafana/pkg/infra/fs"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/loader/initializer"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/setting"
)
var (
logger = log.New("plugin.loader")
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)
type Loader struct {
cfg *setting.Cfg
pluginFinder finder.Finder
pluginInitializer initializer.Initializer
signatureValidator signature.Validator
errs map[string]*plugins.SignatureError
}
func ProvideService(license models.Licensing, cfg *setting.Cfg, authorizer plugins.PluginLoaderAuthorizer) (*Loader, error) {
return New(license, cfg, authorizer), nil
}
func New(license models.Licensing, cfg *setting.Cfg, authorizer plugins.PluginLoaderAuthorizer) *Loader {
return &Loader{
cfg: cfg,
pluginFinder: finder.New(cfg),
pluginInitializer: initializer.New(cfg, license),
signatureValidator: signature.NewValidator(cfg, authorizer),
errs: make(map[string]*plugins.SignatureError),
}
}
func (l *Loader) Load(paths []string, ignore map[string]struct{}) ([]*plugins.Plugin, error) {
pluginJSONPaths, err := l.pluginFinder.Find(paths)
if err != nil {
logger.Error("plugin finder encountered an error", "err", err)
}
return l.loadPlugins(pluginJSONPaths, ignore)
}
func (l *Loader) LoadWithFactory(path string, factory backendplugin.PluginFactoryFunc) (*plugins.Plugin, error) {
p, err := l.load(path, map[string]struct{}{})
if err != nil {
logger.Error("failed to load core plugin", "err", err)
return nil, err
}
err = l.pluginInitializer.InitializeWithFactory(p, factory)
return p, err
}
func (l *Loader) load(path string, ignore map[string]struct{}) (*plugins.Plugin, error) {
pluginJSONPaths, err := l.pluginFinder.Find([]string{path})
if err != nil {
logger.Error("failed to find plugin", "err", err)
return nil, err
}
loadedPlugins, err := l.loadPlugins(pluginJSONPaths, ignore)
if err != nil {
return nil, err
}
if len(loadedPlugins) == 0 {
return nil, fmt.Errorf("could not load plugin at path %s", path)
}
return loadedPlugins[0], nil
}
func (l *Loader) loadPlugins(pluginJSONPaths []string, existingPlugins map[string]struct{}) ([]*plugins.Plugin, error) {
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 {
logger.Warn("Skipping plugin loading as it's plugin.json is invalid", "id", plugin.ID)
continue
}
pluginJSONAbsPath, err := filepath.Abs(pluginJSONPath)
if err != nil {
logger.Warn("Skipping plugin loading as full plugin.json path could not be calculated", "id", plugin.ID)
continue
}
if _, dupe := foundPlugins[filepath.Dir(pluginJSONAbsPath)]; dupe {
logger.Warn("Skipping plugin loading as it's a duplicate", "id", plugin.ID)
continue
}
foundPlugins[filepath.Dir(pluginJSONAbsPath)] = plugin
}
foundPlugins.stripDuplicates(existingPlugins)
// calculate initial signature state
loadedPlugins := make(map[string]*plugins.Plugin)
for pluginDir, pluginJSON := range foundPlugins {
plugin := &plugins.Plugin{
JSONData: pluginJSON,
PluginDir: pluginDir,
Class: l.pluginClass(pluginDir),
}
sig, err := signature.Calculate(logger, plugin)
if err != nil {
logger.Warn("Could not calculate plugin signature state", "pluginID", plugin.ID, "err", err)
continue
}
plugin.Signature = sig.Status
plugin.SignatureType = sig.Type
plugin.SignatureOrg = sig.SigningOrg
plugin.SignedFiles = sig.Files
loadedPlugins[plugin.PluginDir] = plugin
}
// 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
}
}
}
// validate signatures
verifiedPlugins := []*plugins.Plugin{}
for _, plugin := range loadedPlugins {
signingError := l.signatureValidator.Validate(plugin)
if signingError != nil {
logger.Warn("Skipping loading plugin due to problem with signature",
"pluginID", plugin.ID, "status", signingError.SignatureStatus)
plugin.SignatureError = signingError
l.errs[plugin.ID] = signingError
// skip plugin so it will not be loaded any further
continue
}
// clear plugin error if a pre-existing error has since been resolved
delete(l.errs, plugin.ID)
// verify module.js exists for SystemJS to load
if !plugin.IsRenderer() && !plugin.IsCorePlugin() {
module := filepath.Join(plugin.PluginDir, "module.js")
if exists, err := fs.Exists(module); err != nil {
return nil, err
} else if !exists {
logger.Warn("Plugin missing module.js",
"pluginID", plugin.ID,
"warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.",
"path", module)
}
}
verifiedPlugins = append(verifiedPlugins, plugin)
}
for _, p := range verifiedPlugins {
err := l.pluginInitializer.Initialize(p)
if err != nil {
return nil, err
}
}
return verifiedPlugins, nil
}
func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) {
logger.Debug("Loading plugin", "path", pluginJSONPath)
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 {
logger.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)"
}
return plugin, nil
}
func (l *Loader) PluginErrors() []*plugins.Error {
errs := make([]*plugins.Error, 0)
for _, err := range l.errs {
errs = append(errs, &plugins.Error{
PluginID: err.PluginID,
ErrorCode: err.AsErrorCode(),
})
}
return errs
}
func validatePluginJSON(data plugins.JSONData) error {
if data.ID == "" || !data.Type.IsValid() {
return ErrInvalidPluginJSON
}
return nil
}
func (l *Loader) pluginClass(pluginDir string) plugins.Class {
isSubDir := func(base, target string) bool {
path, err := filepath.Rel(base, target)
if err != nil {
return false
}
if !strings.HasPrefix(path, "..") {
return true
}
return false
}
corePluginsDir := filepath.Join(l.cfg.StaticRootPath, "app/plugins")
if isSubDir(corePluginsDir, pluginDir) {
return plugins.Core
}
if isSubDir(l.cfg.BundledPluginsPath, pluginDir) {
return plugins.Bundled
}
return plugins.External
}
type foundPlugins map[string]plugins.JSONData
// stripDuplicates will strip duplicate plugins or plugins that already exist
func (f *foundPlugins) stripDuplicates(existingPlugins map[string]struct{}) {
pluginsByID := make(map[string]struct{})
for path, scannedPlugin := range *f {
if _, existing := existingPlugins[scannedPlugin.ID]; existing {
logger.Debug("Skipping plugin as it's already installed", "plugin", scannedPlugin.ID)
delete(*f, path)
continue
}
pluginsByID[scannedPlugin.ID] = struct{}{}
}
}
+928
View File
@@ -0,0 +1,928 @@
package loader
import (
"errors"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/require"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
"github.com/grafana/grafana/pkg/plugins/manager/loader/initializer"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/setting"
)
var compareOpts = cmpopts.IgnoreFields(plugins.Plugin{}, "client", "log")
func TestLoader_Load(t *testing.T) {
corePluginDir, err := filepath.Abs("./../../../../public")
if err != nil {
t.Errorf("could not construct absolute path of core plugins dir")
return
}
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
tests := []struct {
name string
cfg *setting.Cfg
pluginPaths []string
existingPlugins map[string]struct{}
want []*plugins.Plugin
pluginErrors map[string]*plugins.Error
}{
{
name: "Load a Core plugin",
cfg: &setting.Cfg{
StaticRootPath: corePluginDir,
},
pluginPaths: []string{filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch")},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "cloudwatch",
Type: "datasource",
Name: "CloudWatch",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "https://grafana.com",
},
Description: "Data source for Amazon AWS monitoring service",
Logos: plugins.Logos{
Small: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
Large: "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
},
},
Includes: []*plugins.Includes{
{Name: "EC2", Path: "dashboards/ec2.json", Type: "dashboard", Role: "Viewer"},
{Name: "EBS", Path: "dashboards/EBS.json", Type: "dashboard", Role: "Viewer"},
{Name: "Lambda", Path: "dashboards/Lambda.json", Type: "dashboard", Role: "Viewer"},
{Name: "Logs", Path: "dashboards/Logs.json", Type: "dashboard", Role: "Viewer"},
{Name: "RDS", Path: "dashboards/RDS.json", Type: "dashboard", Role: "Viewer"},
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Category: "cloud",
Annotations: true,
Metrics: true,
Alerting: true,
Logs: true,
QueryOptions: map[string]bool{"minInterval": true},
},
Module: "app/plugins/datasource/cloudwatch/module",
BaseURL: "public/app/plugins/datasource/cloudwatch",
PluginDir: filepath.Join(corePluginDir, "app/plugins/datasource/cloudwatch"),
Signature: "internal",
Class: "core",
},
},
},
{
name: "Load a Bundled plugin",
cfg: &setting.Cfg{
BundledPluginsPath: filepath.Join(parentDir, "testdata"),
},
pluginPaths: []string{"../testdata/valid-v2-signature"},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Will Browne",
URL: "https://willbrowne.com",
},
Version: "1.0.0",
Logos: plugins.Logos{
Small: "public/img/icn-datasource.svg",
Large: "public/img/icn-datasource.svg",
},
Description: "Test",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Executable: "test",
Backend: true,
State: "alpha",
},
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
PluginDir: filepath.Join(parentDir, "testdata/valid-v2-signature/plugin/"),
Signature: "valid",
SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs",
Class: "bundled",
},
},
}, {
name: "Load an External plugin",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
},
pluginPaths: []string{"../testdata/symbolic-plugin-dirs"},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test-app",
Type: "app",
Name: "Test App",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Test Inc.",
URL: "http://test.com",
},
Logos: plugins.Logos{
Small: "public/plugins/test-app/img/logo_small.png",
Large: "public/plugins/test-app/img/logo_large.png",
},
Links: []plugins.InfoLink{
{Name: "Project site", URL: "http://project.com"},
{Name: "License & Terms", URL: "http://license.com"},
},
Description: "Official Grafana Test App & Dashboard bundle",
Screenshots: []plugins.Screenshots{
{Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"},
{Path: "public/plugins/test-app/img/screenshot2.png", Name: "img2"},
},
Version: "1.0.0",
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: "Viewer",
Slug: "nginx-connections",
},
{
Name: "Nginx Memory",
Path: "dashboards/memory.json",
Type: "dashboard",
Role: "Viewer",
Slug: "nginx-memory",
},
{
Name: "Nginx Panel",
Type: "panel",
Role: "Viewer",
Slug: "nginx-panel"},
{
Name: "Nginx Datasource",
Type: "datasource",
Role: "Viewer",
Slug: "nginx-datasource",
},
},
},
Class: plugins.External,
Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app",
PluginDir: filepath.Join(parentDir, "testdata/includes-symlinks"),
Signature: "valid",
SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs",
},
},
}, {
name: "Load an unsigned plugin (development)",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
Env: "development",
},
pluginPaths: []string{"../testdata/unsigned-datasource"},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "https://grafana.com",
},
Logos: plugins.Logos{
Small: "public/img/icn-datasource.svg",
Large: "public/img/icn-datasource.svg",
},
Description: "Test",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Backend: true,
State: plugins.AlphaRelease,
},
Class: plugins.External,
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
PluginDir: filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
Signature: "unsigned",
},
},
}, {
name: "Load an unsigned plugin (production)",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
Env: "production",
},
pluginPaths: []string{"../testdata/unsigned-datasource"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
ErrorCode: "signatureMissing",
},
},
},
{
name: "Load an unsigned plugin using PluginsAllowUnsigned config (production)",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
Env: "production",
PluginsAllowUnsigned: []string{"test"},
},
pluginPaths: []string{"../testdata/unsigned-datasource"},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "https://grafana.com",
},
Logos: plugins.Logos{
Small: "public/img/icn-datasource.svg",
Large: "public/img/icn-datasource.svg",
},
Description: "Test",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Backend: true,
State: plugins.AlphaRelease,
},
Class: plugins.External,
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
PluginDir: filepath.Join(parentDir, "testdata/unsigned-datasource/plugin"),
Signature: "unsigned",
},
},
},
{
name: "Load an unsigned plugin with modified signature (production)",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
Env: "production",
},
pluginPaths: []string{"../testdata/lacking-files"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
ErrorCode: "signatureModified",
},
},
},
{
name: "Load an unsigned plugin with modified signature using PluginsAllowUnsigned config (production) still includes a signing error",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
Env: "production",
PluginsAllowUnsigned: []string{"test"},
},
pluginPaths: []string{"../testdata/lacking-files"},
want: []*plugins.Plugin{},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
ErrorCode: "signatureModified",
},
},
},
}
for _, tt := range tests {
l := newLoader(tt.cfg)
t.Run(tt.name, func(t *testing.T) {
got, err := l.Load(tt.pluginPaths, tt.existingPlugins)
require.NoError(t, err)
if !cmp.Equal(got, tt.want, compareOpts) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts))
}
pluginErrs := l.PluginErrors()
assert.Equal(t, len(tt.pluginErrors), len(pluginErrs))
for _, pluginErr := range pluginErrs {
assert.Equal(t, tt.pluginErrors[pluginErr.PluginID], pluginErr)
}
})
}
}
func TestLoader_Load_MultiplePlugins(t *testing.T) {
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
t.Run("Load multiple", func(t *testing.T) {
tests := []struct {
name string
cfg *setting.Cfg
pluginPaths []string
appURL string
existingPlugins map[string]struct{}
want []*plugins.Plugin
pluginErrors map[string]*plugins.Error
}{
{
name: "Load multiple plugins (broken, valid, unsigned)",
cfg: &setting.Cfg{
PluginsPath: filepath.Join(parentDir),
},
appURL: "http://localhost:3000",
pluginPaths: []string{
"../testdata/invalid-plugin-json", // test-app
"../testdata/valid-v2-pvt-signature", // test
"../testdata/unsigned-panel", // test-panel
},
want: []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Will Browne",
URL: "https://willbrowne.com",
},
Logos: plugins.Logos{
Small: "public/img/icn-datasource.svg",
Large: "public/img/icn-datasource.svg",
},
Description: "Test",
Version: "1.0.0",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Backend: true,
Executable: "test",
State: plugins.AlphaRelease,
},
Class: plugins.External,
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
PluginDir: filepath.Join(parentDir, "testdata/valid-v2-pvt-signature/plugin"),
Signature: "valid",
SignatureType: plugins.PrivateSignature,
SignatureOrg: "Will Browne",
},
},
pluginErrors: map[string]*plugins.Error{
"test": {
PluginID: "test",
ErrorCode: "signatureMissing",
},
},
},
}
for _, tt := range tests {
l := newLoader(tt.cfg)
t.Run(tt.name, func(t *testing.T) {
origAppURL := setting.AppUrl
t.Cleanup(func() {
setting.AppUrl = origAppURL
})
setting.AppUrl = tt.appURL
got, err := l.Load(tt.pluginPaths, tt.existingPlugins)
require.NoError(t, err)
sort.SliceStable(got, func(i, j int) bool {
return got[i].ID < got[j].ID
})
if !cmp.Equal(got, tt.want, compareOpts) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, tt.want, compareOpts))
}
})
}
})
}
func TestLoader_Signature_RootURL(t *testing.T) {
const defaultAppURL = "http://localhost:3000/grafana"
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
t.Run("Private signature verification ignores trailing slash in root URL", func(t *testing.T) {
origAppURL := setting.AppUrl
origAppSubURL := setting.AppSubUrl
t.Cleanup(func() {
setting.AppUrl = origAppURL
setting.AppSubUrl = origAppSubURL
})
setting.AppUrl = defaultAppURL
paths := []string{"../testdata/valid-v2-pvt-signature-root-url-uri"}
expected := []*plugins.Plugin{
{
JSONData: plugins.JSONData{
ID: "test",
Type: "datasource",
Name: "Test",
Info: plugins.Info{
Author: plugins.InfoLink{Name: "Will Browne", URL: "https://willbrowne.com"},
Description: "Test",
Logos: plugins.Logos{
Small: "public/img/icn-datasource.svg",
Large: "public/img/icn-datasource.svg",
},
Version: "1.0.0",
},
State: plugins.AlphaRelease,
Dependencies: plugins.Dependencies{GrafanaVersion: "*", Plugins: []plugins.Dependency{}},
Backend: true,
Executable: "test",
},
PluginDir: filepath.Join(parentDir, "/testdata/valid-v2-pvt-signature-root-url-uri/plugin"),
Class: "external",
Signature: "valid",
SignatureType: "private",
SignatureOrg: "Will Browne",
Module: "plugins/test/module",
BaseURL: "public/plugins/test",
},
}
l := newLoader(&setting.Cfg{PluginsPath: filepath.Join(parentDir)})
got, err := l.Load(paths, map[string]struct{}{})
assert.NoError(t, err)
if !cmp.Equal(got, expected, compareOpts) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts))
}
})
}
func TestLoader_Load_DuplicatePlugins(t *testing.T) {
t.Run("Load duplicate plugin folders", func(t *testing.T) {
pluginDir, err := filepath.Abs("../testdata/test-app")
if err != nil {
t.Errorf("could not construct absolute path of plugin dir")
return
}
expected := []*plugins.Plugin{
{
JSONData: 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: "public/plugins/test-app/img/logo_small.png",
Large: "public/plugins/test-app/img/logo_large.png",
},
Screenshots: []plugins.Screenshots{
{Path: "public/plugins/test-app/img/screenshot1.png", Name: "img1"},
{Path: "public/plugins/test-app/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: "Viewer", Slug: "nginx-connections"},
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard", Role: "Viewer", Slug: "nginx-memory"},
{Name: "Nginx Panel", Type: "panel", Role: "Viewer", Slug: "nginx-panel"},
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Slug: "nginx-datasource"},
},
Backend: false,
},
PluginDir: pluginDir,
Class: plugins.External,
Signature: plugins.SignatureValid,
SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs",
Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app",
},
}
l := newLoader(&setting.Cfg{
PluginsPath: filepath.Dir(pluginDir),
})
got, err := l.Load([]string{pluginDir, pluginDir}, map[string]struct{}{})
assert.NoError(t, err)
if !cmp.Equal(got, expected, compareOpts) {
t.Fatalf("Result mismatch (-want +got):\n%s", cmp.Diff(got, expected, compareOpts))
}
})
}
func TestLoader_loadNestedPlugins(t *testing.T) {
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of root dir")
return
}
parent := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test-ds",
Type: "datasource",
Name: "Parent",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "http://grafana.com",
},
Logos: plugins.Logos{
Small: "public/img/icn-datasource.svg",
Large: "public/img/icn-datasource.svg",
},
Description: "Parent plugin",
Version: "1.0.0",
Updated: "2020-10-20",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Backend: true,
},
Module: "plugins/test-ds/module",
BaseURL: "public/plugins/test-ds",
PluginDir: filepath.Join(parentDir, "testdata/nested-plugins/parent"),
Signature: "valid",
SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs",
Class: "external",
}
child := &plugins.Plugin{
JSONData: plugins.JSONData{
ID: "test-panel",
Type: "panel",
Name: "Child",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
URL: "http://grafana.com",
},
Logos: plugins.Logos{
Small: "public/img/icn-panel.svg",
Large: "public/img/icn-panel.svg",
},
Description: "Child plugin",
Version: "1.0.1",
Updated: "2020-10-30",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
},
Module: "plugins/test-panel/module",
BaseURL: "public/plugins/test-panel",
PluginDir: filepath.Join(parentDir, "testdata/nested-plugins/parent/nested"),
Signature: "valid",
SignatureType: plugins.GrafanaSignature,
SignatureOrg: "Grafana Labs",
Class: "external",
}
parent.Children = []*plugins.Plugin{child}
child.Parent = parent
t.Run("Load nested External plugins", func(t *testing.T) {
expected := []*plugins.Plugin{parent, child}
l := newLoader(&setting.Cfg{
PluginsPath: parentDir,
})
got, err := l.Load([]string{"../testdata/nested-plugins"}, map[string]struct{}{})
assert.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))
}
})
t.Run("Load will exclude plugins that already exist", func(t *testing.T) {
// parent/child links will not be created when either plugins are provided in the existingPlugins map
parent.Children = nil
expected := []*plugins.Plugin{parent}
l := newLoader(&setting.Cfg{
PluginsPath: parentDir,
})
got, err := l.Load([]string{"../testdata/nested-plugins"}, map[string]struct{}{
"test-panel": {},
})
assert.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))
}
})
}
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"},
{Name: "Nginx Memory", Path: "dashboards/memory.json", Type: "dashboard"},
{Name: "Nginx Panel", Type: "panel"},
{Name: "Nginx Datasource", Type: "datasource"},
},
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_pluginClass(t *testing.T) {
type args struct {
pluginDir string
cfg *setting.Cfg
}
tests := []struct {
name string
args args
expected plugins.Class
}{
{
name: "Core plugin class",
args: args{
pluginDir: "/root/app/plugins/test-app",
cfg: &setting.Cfg{
StaticRootPath: "/root",
},
},
expected: plugins.Core,
},
{
name: "Bundled plugin class",
args: args{
pluginDir: "/test-app",
cfg: &setting.Cfg{
BundledPluginsPath: "/test-app",
},
},
expected: plugins.Bundled,
},
{
name: "External plugin class",
args: args{
pluginDir: "/test-app",
cfg: &setting.Cfg{
PluginsPath: "/test-app",
},
},
expected: plugins.External,
},
{
name: "External plugin class",
args: args{
pluginDir: "/test-app",
cfg: &setting.Cfg{
PluginsPath: "/root",
},
},
expected: plugins.External,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
l := newLoader(tt.args.cfg)
got := l.pluginClass(tt.args.pluginDir)
assert.Equal(t, tt.expected, got)
})
}
}
func newLoader(cfg *setting.Cfg) *Loader {
return &Loader{
cfg: cfg,
pluginFinder: finder.New(cfg),
pluginInitializer: initializer.New(cfg, &fakeLicensingService{}),
signatureValidator: signature.NewValidator(cfg, &signature.UnsignedPluginAuthorizer{Cfg: cfg}),
errs: make(map[string]*plugins.SignatureError),
}
}
type fakeLicensingService struct {
edition string
hasLicense bool
tokenRaw string
}
func (t *fakeLicensingService) HasLicense() bool {
return t.hasLicense
}
func (t *fakeLicensingService) Expiry() int64 {
return 0
}
func (t *fakeLicensingService) Edition() string {
return t.edition
}
func (t *fakeLicensingService) StateInfo() string {
return ""
}
func (t *fakeLicensingService) ContentDeliveryPrefix() string {
return ""
}
func (t *fakeLicensingService) LicenseURL(showAdminLicensingPage bool) string {
return ""
}
func (t *fakeLicensingService) HasValidLicense() bool {
return false
}
func (t *fakeLicensingService) Environment() map[string]string {
return map[string]string{"GF_ENTERPRISE_LICENSE_TEXT": t.tokenRaw}
}