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
-125
View File
@@ -1,125 +0,0 @@
package plugins
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"github.com/gosimple/slug"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
)
type AppPlugin struct {
FrontendPluginBase
Routes []*AppPluginRoute `json:"routes"`
AutoEnabled bool `json:"autoEnabled"`
FoundChildPlugins []*PluginInclude `json:"-"`
Pinned bool `json:"-"`
Executable string `json:"executable,omitempty"`
}
// AppPluginRoute describes a plugin route that is defined in
// the plugin.json file for a plugin.
type AppPluginRoute struct {
Path string `json:"path"`
Method string `json:"method"`
ReqRole models.RoleType `json:"reqRole"`
URL string `json:"url"`
URLParams []AppPluginRouteURLParam `json:"urlParams"`
Headers []AppPluginRouteHeader `json:"headers"`
AuthType string `json:"authType"`
TokenAuth *JwtTokenAuth `json:"tokenAuth"`
JwtTokenAuth *JwtTokenAuth `json:"jwtTokenAuth"`
Body json.RawMessage `json:"body"`
}
// AppPluginRouteHeader describes an HTTP header that is forwarded with
// the proxied request for a plugin route
type AppPluginRouteHeader struct {
Name string `json:"name"`
Content string `json:"content"`
}
// AppPluginRouteURLParam describes query string parameters for
// a url in a plugin route
type AppPluginRouteURLParam struct {
Name string `json:"name"`
Content string `json:"content"`
}
// JwtTokenAuth struct is both for normal Token Auth and JWT Token Auth with
// an uploaded JWT file.
type JwtTokenAuth struct {
Url string `json:"url"`
Scopes []string `json:"scopes"`
Params map[string]string `json:"params"`
}
func (app *AppPlugin) Load(decoder *json.Decoder, base *PluginBase, backendPluginManager backendplugin.Manager) (
interface{}, error) {
if err := decoder.Decode(app); err != nil {
return nil, err
}
if app.Backend {
cmd := ComposePluginStartCommand(app.Executable)
fullpath := filepath.Join(base.PluginDir, cmd)
factory := grpcplugin.NewBackendPlugin(app.Id, fullpath)
if err := backendPluginManager.RegisterAndStart(context.Background(), app.Id, factory); err != nil {
return nil, errutil.Wrapf(err, "failed to register backend plugin")
}
}
return app, nil
}
func (app *AppPlugin) InitApp(panels map[string]*PanelPlugin, dataSources map[string]*DataSourcePlugin,
cfg *setting.Cfg) []*PluginStaticRoute {
staticRoutes := app.InitFrontendPlugin(cfg)
// check if we have child panels
for _, panel := range panels {
if strings.HasPrefix(panel.PluginDir, app.PluginDir) {
panel.setPathsBasedOnApp(app, cfg)
app.FoundChildPlugins = append(app.FoundChildPlugins, &PluginInclude{
Name: panel.Name,
Id: panel.Id,
Type: panel.Type,
})
}
}
// check if we have child datasources
for _, ds := range dataSources {
if strings.HasPrefix(ds.PluginDir, app.PluginDir) {
ds.setPathsBasedOnApp(app, cfg)
app.FoundChildPlugins = append(app.FoundChildPlugins, &PluginInclude{
Name: ds.Name,
Id: ds.Id,
Type: ds.Type,
})
}
}
// slugify pages
for _, include := range app.Includes {
if include.Slug == "" {
include.Slug = slug.Make(include.Name)
}
if include.Type == "page" && include.DefaultNav {
app.DefaultNavUrl = cfg.AppSubURL + "/plugins/" + app.Id + "/page/" + include.Slug
}
if include.Type == "dashboard" && include.DefaultNav {
app.DefaultNavUrl = cfg.AppSubURL + include.GetSlugOrUIDLink()
}
}
return staticRoutes
}
+12
View File
@@ -17,3 +17,15 @@ func ComposePluginStartCommand(executable string) string {
return fmt.Sprintf("%s_%s_%s%s", executable, os, strings.ToLower(arch), extension)
}
func ComposeRendererStartCommand() string {
os := strings.ToLower(runtime.GOOS)
arch := runtime.GOARCH
extension := ""
if os == "windows" {
extension = ".exe"
}
return fmt.Sprintf("%s_%s_%s%s", "plugin_start", os, strings.ToLower(arch), extension)
}
-25
View File
@@ -5,33 +5,8 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
)
// Manager manages backend plugins.
type Manager interface {
//Register registers a backend plugin
Register(pluginID string, factory PluginFactoryFunc) error
// RegisterAndStart registers and starts a backend plugin
RegisterAndStart(ctx context.Context, pluginID string, factory PluginFactoryFunc) error
// UnregisterAndStop unregisters and stops a backend plugin
UnregisterAndStop(ctx context.Context, pluginID string) error
// IsRegistered checks if a plugin is registered with the manager
IsRegistered(pluginID string) bool
// StartPlugin starts a non-managed backend plugin
StartPlugin(ctx context.Context, pluginID string) error
// CollectMetrics collects metrics from a registered backend plugin.
CollectMetrics(ctx context.Context, pluginID string) (*backend.CollectMetricsResult, error)
// CheckHealth checks the health of a registered backend plugin.
CheckHealth(ctx context.Context, pCtx backend.PluginContext) (*backend.CheckHealthResult, error)
// QueryData query data from a registered backend plugin.
QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error)
// CallResource calls a plugin resource.
CallResource(pCtx backend.PluginContext, reqCtx *models.ReqContext, path string)
// Get plugin by its ID.
Get(pluginID string) (Plugin, bool)
}
// Plugin is the backend plugin interface.
type Plugin interface {
PluginID() string
@@ -2,10 +2,8 @@
package instrumentation
import (
"context"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/prometheus/client_golang/prometheus"
)
@@ -68,19 +66,3 @@ func InstrumentCallResourceRequest(pluginID string, fn func() error) error {
func InstrumentQueryDataRequest(pluginID string, fn func() error) error {
return instrumentPluginRequest(pluginID, "queryData", fn)
}
// InstrumentQueryDataHandler wraps a backend.QueryDataHandler with instrumentation of success rate and latency.
func InstrumentQueryDataHandler(handler backend.QueryDataHandler) backend.QueryDataHandler {
if handler == nil {
return nil
}
return backend.QueryDataHandlerFunc(func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
var resp *backend.QueryDataResponse
err := InstrumentQueryDataRequest(req.PluginContext.PluginID, func() (innerErr error) {
resp, innerErr = handler.QueryData(ctx, req)
return
})
return resp, err
})
}
@@ -1,531 +0,0 @@
package manager
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/grafana/grafana-aws-sdk/pkg/awsds"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/instrumentation"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/util/proxyutil"
)
func ProvideService(cfg *setting.Cfg, licensing models.Licensing,
pluginRequestValidator models.PluginRequestValidator) *Manager {
s := &Manager{
Cfg: cfg,
License: licensing,
PluginRequestValidator: pluginRequestValidator,
logger: log.New("plugins.backend"),
plugins: map[string]backendplugin.Plugin{},
}
return s
}
type Manager struct {
Cfg *setting.Cfg
License models.Licensing
PluginRequestValidator models.PluginRequestValidator
pluginsMu sync.RWMutex
plugins map[string]backendplugin.Plugin
logger log.Logger
}
func (m *Manager) Run(ctx context.Context) error {
<-ctx.Done()
m.stop(ctx)
return ctx.Err()
}
// Register registers a backend plugin
func (m *Manager) Register(pluginID string, factory backendplugin.PluginFactoryFunc) error {
m.logger.Debug("Registering backend plugin", "pluginId", pluginID)
m.pluginsMu.Lock()
defer m.pluginsMu.Unlock()
if _, exists := m.plugins[pluginID]; exists {
return fmt.Errorf("backend plugin %s already registered", pluginID)
}
hostEnv := []string{
fmt.Sprintf("GF_VERSION=%s", m.Cfg.BuildVersion),
fmt.Sprintf("GF_EDITION=%s", m.License.Edition()),
}
if m.License.HasLicense() {
hostEnv = append(
hostEnv,
fmt.Sprintf("GF_ENTERPRISE_LICENSE_PATH=%s", m.Cfg.EnterpriseLicensePath),
)
if envProvider, ok := m.License.(models.LicenseEnvironment); ok {
for k, v := range envProvider.Environment() {
hostEnv = append(hostEnv, fmt.Sprintf("%s=%s", k, v))
}
}
}
hostEnv = append(hostEnv, m.getAWSEnvironmentVariables()...)
hostEnv = append(hostEnv, m.getAzureEnvironmentVariables()...)
pluginSettings := getPluginSettings(pluginID, m.Cfg)
env := pluginSettings.ToEnv("GF_PLUGIN", hostEnv)
pluginLogger := m.logger.New("pluginId", pluginID)
plugin, err := factory(pluginID, pluginLogger, env)
if err != nil {
return err
}
m.plugins[pluginID] = plugin
m.logger.Debug("Backend plugin registered", "pluginId", pluginID)
return nil
}
// RegisterAndStart registers and starts a backend plugin
func (m *Manager) RegisterAndStart(ctx context.Context, pluginID string, factory backendplugin.PluginFactoryFunc) error {
err := m.Register(pluginID, factory)
if err != nil {
return err
}
p, exists := m.Get(pluginID)
if !exists {
return fmt.Errorf("backend plugin %s is not registered", pluginID)
}
m.start(ctx, p)
return nil
}
// UnregisterAndStop unregisters and stops a backend plugin
func (m *Manager) UnregisterAndStop(ctx context.Context, pluginID string) error {
m.logger.Debug("Unregistering backend plugin", "pluginId", pluginID)
m.pluginsMu.Lock()
defer m.pluginsMu.Unlock()
p, exists := m.plugins[pluginID]
if !exists {
return fmt.Errorf("backend plugin %s is not registered", pluginID)
}
m.logger.Debug("Stopping backend plugin process", "pluginId", pluginID)
if err := p.Decommission(); err != nil {
return err
}
if err := p.Stop(ctx); err != nil {
return err
}
delete(m.plugins, pluginID)
m.logger.Debug("Backend plugin unregistered", "pluginId", pluginID)
return nil
}
func (m *Manager) IsRegistered(pluginID string) bool {
p, _ := m.Get(pluginID)
return p != nil && !p.IsDecommissioned()
}
func (m *Manager) Get(pluginID string) (backendplugin.Plugin, bool) {
m.pluginsMu.RLock()
p, ok := m.plugins[pluginID]
m.pluginsMu.RUnlock()
if ok && p.IsDecommissioned() {
return nil, false
}
return p, ok
}
func (m *Manager) getAWSEnvironmentVariables() []string {
variables := []string{}
if m.Cfg.AWSAssumeRoleEnabled {
variables = append(variables, awsds.AssumeRoleEnabledEnvVarKeyName+"=true")
}
if len(m.Cfg.AWSAllowedAuthProviders) > 0 {
variables = append(variables, awsds.AllowedAuthProvidersEnvVarKeyName+"="+strings.Join(m.Cfg.AWSAllowedAuthProviders, ","))
}
return variables
}
func (m *Manager) getAzureEnvironmentVariables() []string {
variables := []string{}
if m.Cfg.Azure.Cloud != "" {
variables = append(variables, "AZURE_CLOUD="+m.Cfg.Azure.Cloud)
}
if m.Cfg.Azure.ManagedIdentityClientId != "" {
variables = append(variables, "AZURE_MANAGED_IDENTITY_CLIENT_ID="+m.Cfg.Azure.ManagedIdentityClientId)
}
if m.Cfg.Azure.ManagedIdentityEnabled {
variables = append(variables, "AZURE_MANAGED_IDENTITY_ENABLED=true")
}
return variables
}
// start starts a managed backend plugin
func (m *Manager) start(ctx context.Context, p backendplugin.Plugin) {
if !p.IsManaged() {
return
}
if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil {
p.Logger().Error("Failed to start plugin", "error", err)
}
}
// StartPlugin starts a non-managed backend plugin
func (m *Manager) StartPlugin(ctx context.Context, pluginID string) error {
m.pluginsMu.RLock()
p, registered := m.plugins[pluginID]
m.pluginsMu.RUnlock()
if !registered {
return backendplugin.ErrPluginNotRegistered
}
if p.IsManaged() {
return errors.New("backend plugin is managed and cannot be manually started")
}
return startPluginAndRestartKilledProcesses(ctx, p)
}
// stop stops all managed backend plugins
func (m *Manager) stop(ctx context.Context) {
m.pluginsMu.RLock()
defer m.pluginsMu.RUnlock()
var wg sync.WaitGroup
for _, p := range m.plugins {
wg.Add(1)
go func(p backendplugin.Plugin, ctx context.Context) {
defer wg.Done()
p.Logger().Debug("Stopping plugin")
if err := p.Stop(ctx); err != nil {
p.Logger().Error("Failed to stop plugin", "error", err)
}
p.Logger().Debug("Plugin stopped")
}(p, ctx)
}
wg.Wait()
}
// CollectMetrics collects metrics from a registered backend plugin.
func (m *Manager) CollectMetrics(ctx context.Context, pluginID string) (*backend.CollectMetricsResult, error) {
p, registered := m.Get(pluginID)
if !registered {
return nil, backendplugin.ErrPluginNotRegistered
}
var resp *backend.CollectMetricsResult
err := instrumentation.InstrumentCollectMetrics(p.PluginID(), func() (innerErr error) {
resp, innerErr = p.CollectMetrics(ctx)
return
})
if err != nil {
return nil, err
}
return resp, nil
}
// CheckHealth checks the health of a registered backend plugin.
func (m *Manager) CheckHealth(ctx context.Context, pluginContext backend.PluginContext) (*backend.CheckHealthResult, error) {
var dsURL string
if pluginContext.DataSourceInstanceSettings != nil {
dsURL = pluginContext.DataSourceInstanceSettings.URL
}
err := m.PluginRequestValidator.Validate(dsURL, nil)
if err != nil {
return &backend.CheckHealthResult{
Status: http.StatusForbidden,
Message: "Access denied",
}, nil
}
p, registered := m.Get(pluginContext.PluginID)
if !registered {
return nil, backendplugin.ErrPluginNotRegistered
}
var resp *backend.CheckHealthResult
err = instrumentation.InstrumentCheckHealthRequest(p.PluginID(), func() (innerErr error) {
resp, innerErr = p.CheckHealth(ctx, &backend.CheckHealthRequest{PluginContext: pluginContext})
return
})
if err != nil {
if errors.Is(err, backendplugin.ErrMethodNotImplemented) {
return nil, err
}
if errors.Is(err, backendplugin.ErrPluginUnavailable) {
return nil, err
}
return nil, errutil.Wrap("failed to check plugin health", backendplugin.ErrHealthCheckFailed)
}
return resp, nil
}
func (m *Manager) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
p, registered := m.Get(req.PluginContext.PluginID)
if !registered {
return nil, backendplugin.ErrPluginNotRegistered
}
var resp *backend.QueryDataResponse
err := instrumentation.InstrumentQueryDataRequest(p.PluginID(), func() (innerErr error) {
resp, innerErr = p.QueryData(ctx, req)
return
})
if err != nil {
if errors.Is(err, backendplugin.ErrMethodNotImplemented) {
return nil, err
}
if errors.Is(err, backendplugin.ErrPluginUnavailable) {
return nil, err
}
return nil, errutil.Wrap("failed to query data", err)
}
return resp, nil
}
type keepCookiesJSONModel struct {
KeepCookies []string `json:"keepCookies"`
}
func (m *Manager) callResourceInternal(w http.ResponseWriter, req *http.Request, pCtx backend.PluginContext) error {
p, registered := m.Get(pCtx.PluginID)
if !registered {
return backendplugin.ErrPluginNotRegistered
}
keepCookieModel := keepCookiesJSONModel{}
if dis := pCtx.DataSourceInstanceSettings; dis != nil {
err := json.Unmarshal(dis.JSONData, &keepCookieModel)
if err != nil {
p.Logger().Error("Failed to to unpack JSONData in datasource instance settings", "error", err)
}
}
proxyutil.ClearCookieHeader(req, keepCookieModel.KeepCookies)
proxyutil.PrepareProxyRequest(req)
body, err := ioutil.ReadAll(req.Body)
if err != nil {
return fmt.Errorf("failed to read request body: %w", err)
}
crReq := &backend.CallResourceRequest{
PluginContext: pCtx,
Path: req.URL.Path,
Method: req.Method,
URL: req.URL.String(),
Headers: req.Header,
Body: body,
}
return instrumentation.InstrumentCallResourceRequest(p.PluginID(), func() error {
childCtx, cancel := context.WithCancel(req.Context())
defer cancel()
stream := newCallResourceResponseStream(childCtx)
var wg sync.WaitGroup
wg.Add(1)
defer func() {
if err := stream.Close(); err != nil {
m.logger.Warn("Failed to close stream", "err", err)
}
wg.Wait()
}()
var flushStreamErr error
go func() {
flushStreamErr = flushStream(p, stream, w)
wg.Done()
}()
if err := p.CallResource(req.Context(), crReq, stream); err != nil {
return err
}
return flushStreamErr
})
}
// CallResource calls a plugin resource.
func (m *Manager) CallResource(pCtx backend.PluginContext, reqCtx *models.ReqContext, path string) {
var dsURL string
if pCtx.DataSourceInstanceSettings != nil {
dsURL = pCtx.DataSourceInstanceSettings.URL
}
err := m.PluginRequestValidator.Validate(dsURL, reqCtx.Req)
if err != nil {
reqCtx.JsonApiErr(http.StatusForbidden, "Access denied", err)
return
}
clonedReq := reqCtx.Req.Clone(reqCtx.Req.Context())
rawURL := path
if clonedReq.URL.RawQuery != "" {
rawURL += "?" + clonedReq.URL.RawQuery
}
urlPath, err := url.Parse(rawURL)
if err != nil {
handleCallResourceError(err, reqCtx)
return
}
clonedReq.URL = urlPath
err = m.callResourceInternal(reqCtx.Resp, clonedReq, pCtx)
if err != nil {
handleCallResourceError(err, reqCtx)
}
}
func handleCallResourceError(err error, reqCtx *models.ReqContext) {
if errors.Is(err, backendplugin.ErrPluginUnavailable) {
reqCtx.JsonApiErr(503, "Plugin unavailable", err)
return
}
if errors.Is(err, backendplugin.ErrMethodNotImplemented) {
reqCtx.JsonApiErr(404, "Not found", err)
return
}
reqCtx.JsonApiErr(500, "Failed to call resource", err)
}
func flushStream(plugin backendplugin.Plugin, stream callResourceClientResponseStream, w http.ResponseWriter) error {
processedStreams := 0
for {
resp, err := stream.Recv()
if errors.Is(err, io.EOF) {
if processedStreams == 0 {
return errors.New("received empty resource response")
}
return nil
}
if err != nil {
if processedStreams == 0 {
return errutil.Wrap("failed to receive response from resource call", err)
}
plugin.Logger().Error("Failed to receive response from resource call", "error", err)
return stream.Close()
}
// Expected that headers and status are only part of first stream
if processedStreams == 0 && resp.Headers != nil {
// Make sure a content type always is returned in response
if _, exists := resp.Headers["Content-Type"]; !exists {
resp.Headers["Content-Type"] = []string{"application/json"}
}
for k, values := range resp.Headers {
// Due to security reasons we don't want to forward
// cookies from a backend plugin to clients/browsers.
if k == "Set-Cookie" {
continue
}
for _, v := range values {
// TODO: Figure out if we should use Set here instead
// nolint:gocritic
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.Status)
}
if _, err := w.Write(resp.Body); err != nil {
plugin.Logger().Error("Failed to write resource response", "error", err)
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
processedStreams++
}
}
func startPluginAndRestartKilledProcesses(ctx context.Context, p backendplugin.Plugin) error {
if err := p.Start(ctx); err != nil {
return err
}
go func(ctx context.Context, p backendplugin.Plugin) {
if err := restartKilledProcess(ctx, p); err != nil {
p.Logger().Error("Attempt to restart killed plugin process failed", "error", err)
}
}(ctx, p)
return nil
}
func restartKilledProcess(ctx context.Context, p backendplugin.Plugin) error {
ticker := time.NewTicker(time.Second * 1)
for {
select {
case <-ctx.Done():
if err := ctx.Err(); err != nil && !errors.Is(err, context.Canceled) {
return err
}
return nil
case <-ticker.C:
if p.IsDecommissioned() {
p.Logger().Debug("Plugin decommissioned")
return nil
}
if !p.Exited() {
continue
}
p.Logger().Debug("Restarting plugin")
if err := p.Start(ctx); err != nil {
p.Logger().Error("Failed to restart plugin", "error", err)
continue
}
p.Logger().Debug("Plugin restarted")
}
}
}
// callResourceClientResponseStream is used for receiving resource call responses.
type callResourceClientResponseStream interface {
Recv() (*backend.CallResourceResponse, error)
Close() error
}
@@ -1,511 +0,0 @@
package manager
import (
"bytes"
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/grafana/grafana-aws-sdk/pkg/awsds"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
const testPluginID = "test-plugin"
func TestManager(t *testing.T) {
newManagerScenario(t, false, func(t *testing.T, ctx *managerScenarioCtx) {
t.Run("Unregistered plugin scenario", func(t *testing.T) {
err := ctx.manager.StartPlugin(context.Background(), testPluginID)
require.Equal(t, backendplugin.ErrPluginNotRegistered, err)
_, err = ctx.manager.CollectMetrics(context.Background(), testPluginID)
require.Equal(t, backendplugin.ErrPluginNotRegistered, err)
_, err = ctx.manager.CheckHealth(context.Background(), backend.PluginContext{PluginID: testPluginID})
require.Equal(t, backendplugin.ErrPluginNotRegistered, err)
req, err := http.NewRequest(http.MethodGet, "/test", nil)
require.NoError(t, err)
w := httptest.NewRecorder()
err = ctx.manager.callResourceInternal(w, req, backend.PluginContext{PluginID: testPluginID})
require.Equal(t, backendplugin.ErrPluginNotRegistered, err)
})
})
newManagerScenario(t, true, func(t *testing.T, ctx *managerScenarioCtx) {
t.Run("Managed plugin scenario", func(t *testing.T) {
ctx.license.edition = "Open Source"
ctx.license.hasLicense = false
ctx.cfg.BuildVersion = "7.0.0"
t.Run("Should be able to register plugin", func(t *testing.T) {
err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory)
require.NoError(t, err)
require.NotNil(t, ctx.plugin)
require.Equal(t, testPluginID, ctx.plugin.pluginID)
require.NotNil(t, ctx.plugin.logger)
require.Equal(t, 1, ctx.plugin.startCount)
require.True(t, ctx.manager.IsRegistered(testPluginID))
t.Run("Should not be able to register an already registered plugin", func(t *testing.T) {
err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory)
require.Equal(t, 1, ctx.plugin.startCount)
require.Error(t, err)
})
t.Run("Should provide expected host environment variables", func(t *testing.T) {
require.Len(t, ctx.env, 7)
require.EqualValues(t, []string{
"GF_VERSION=7.0.0",
"GF_EDITION=Open Source",
fmt.Sprintf("%s=true", awsds.AssumeRoleEnabledEnvVarKeyName),
fmt.Sprintf("%s=keys,credentials", awsds.AllowedAuthProvidersEnvVarKeyName),
"AZURE_CLOUD=AzureCloud",
"AZURE_MANAGED_IDENTITY_CLIENT_ID=client-id",
"AZURE_MANAGED_IDENTITY_ENABLED=true"},
ctx.env)
})
t.Run("When manager runs should start and stop plugin", func(t *testing.T) {
pCtx := context.Background()
cCtx, cancel := context.WithCancel(pCtx)
var wg sync.WaitGroup
wg.Add(1)
var runErr error
go func() {
runErr = ctx.manager.Run(cCtx)
wg.Done()
}()
time.Sleep(time.Millisecond)
cancel()
wg.Wait()
require.Equal(t, context.Canceled, runErr)
require.Equal(t, 1, ctx.plugin.startCount)
require.Equal(t, 1, ctx.plugin.stopCount)
})
t.Run("When manager runs should restart plugin process when killed", func(t *testing.T) {
ctx.plugin.stopCount = 0
ctx.plugin.startCount = 0
pCtx := context.Background()
cCtx, cancel := context.WithCancel(pCtx)
var wgRun sync.WaitGroup
wgRun.Add(1)
var runErr error
go func() {
runErr = ctx.manager.Run(cCtx)
wgRun.Done()
}()
time.Sleep(time.Millisecond)
var wgKill sync.WaitGroup
wgKill.Add(1)
go func() {
ctx.plugin.kill()
for {
if !ctx.plugin.Exited() {
break
}
}
cancel()
wgKill.Done()
}()
wgKill.Wait()
wgRun.Wait()
require.Equal(t, context.Canceled, runErr)
require.Equal(t, 1, ctx.plugin.stopCount)
require.Equal(t, 1, ctx.plugin.startCount)
})
t.Run("Shouldn't be able to start managed plugin", func(t *testing.T) {
err := ctx.manager.StartPlugin(context.Background(), testPluginID)
require.NotNil(t, err)
})
t.Run("Unimplemented handlers", func(t *testing.T) {
t.Run("Collect metrics should return method not implemented error", func(t *testing.T) {
_, err = ctx.manager.CollectMetrics(context.Background(), testPluginID)
require.Equal(t, backendplugin.ErrMethodNotImplemented, err)
})
t.Run("Check health should return method not implemented error", func(t *testing.T) {
_, err = ctx.manager.CheckHealth(context.Background(), backend.PluginContext{PluginID: testPluginID})
require.Equal(t, backendplugin.ErrMethodNotImplemented, err)
})
t.Run("Call resource should return method not implemented error", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/test", bytes.NewReader([]byte{}))
require.NoError(t, err)
w := httptest.NewRecorder()
err = ctx.manager.callResourceInternal(w, req, backend.PluginContext{PluginID: testPluginID})
require.Equal(t, backendplugin.ErrMethodNotImplemented, err)
})
})
t.Run("Implemented handlers", func(t *testing.T) {
t.Run("Collect metrics should return expected result", func(t *testing.T) {
ctx.plugin.CollectMetricsHandlerFunc = func(ctx context.Context) (*backend.CollectMetricsResult, error) {
return &backend.CollectMetricsResult{
PrometheusMetrics: []byte("hello"),
}, nil
}
res, err := ctx.manager.CollectMetrics(context.Background(), testPluginID)
require.NoError(t, err)
require.NotNil(t, res)
require.Equal(t, "hello", string(res.PrometheusMetrics))
})
t.Run("Check health should return expected result", func(t *testing.T) {
json := []byte(`{
"key": "value"
}`)
ctx.plugin.CheckHealthHandlerFunc = func(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
return &backend.CheckHealthResult{
Status: backend.HealthStatusOk,
Message: "All good",
JSONDetails: json,
}, nil
}
res, err := ctx.manager.CheckHealth(context.Background(), backend.PluginContext{PluginID: testPluginID})
require.NoError(t, err)
require.NotNil(t, res)
require.Equal(t, backend.HealthStatusOk, res.Status)
require.Equal(t, "All good", res.Message)
require.Equal(t, json, res.JSONDetails)
})
t.Run("Call resource should return expected response", func(t *testing.T) {
ctx.plugin.CallResourceHandlerFunc = func(ctx context.Context,
req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
return sender.Send(&backend.CallResourceResponse{
Status: http.StatusOK,
})
}
req, err := http.NewRequest(http.MethodGet, "/test", bytes.NewReader([]byte{}))
require.NoError(t, err)
w := httptest.NewRecorder()
err = ctx.manager.callResourceInternal(w, req, backend.PluginContext{PluginID: testPluginID})
require.NoError(t, err)
require.Equal(t, http.StatusOK, w.Code)
})
})
t.Run("Should be able to decommission a running plugin", func(t *testing.T) {
require.True(t, ctx.manager.IsRegistered(testPluginID))
err := ctx.manager.UnregisterAndStop(context.Background(), testPluginID)
require.NoError(t, err)
require.Equal(t, 2, ctx.plugin.stopCount)
require.False(t, ctx.manager.IsRegistered(testPluginID))
p := ctx.manager.plugins[testPluginID]
require.Nil(t, p)
err = ctx.manager.StartPlugin(context.Background(), testPluginID)
require.Equal(t, backendplugin.ErrPluginNotRegistered, err)
})
})
})
})
newManagerScenario(t, false, func(t *testing.T, ctx *managerScenarioCtx) {
t.Run("Unmanaged plugin scenario", func(t *testing.T) {
ctx.license.edition = "Open Source"
ctx.license.hasLicense = false
ctx.cfg.BuildVersion = "7.0.0"
t.Run("Should be able to register plugin", func(t *testing.T) {
err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory)
require.NoError(t, err)
require.True(t, ctx.manager.IsRegistered(testPluginID))
require.False(t, ctx.plugin.managed)
t.Run("When manager runs should not start plugin", func(t *testing.T) {
pCtx := context.Background()
cCtx, cancel := context.WithCancel(pCtx)
var wg sync.WaitGroup
wg.Add(1)
var runErr error
go func() {
runErr = ctx.manager.Run(cCtx)
wg.Done()
}()
go func() {
cancel()
}()
wg.Wait()
require.Equal(t, context.Canceled, runErr)
require.Equal(t, 0, ctx.plugin.startCount)
require.Equal(t, 1, ctx.plugin.stopCount)
})
t.Run("Should be able to start unmanaged plugin and be restarted when process is killed", func(t *testing.T) {
pCtx := context.Background()
cCtx, cancel := context.WithCancel(pCtx)
defer cancel()
err := ctx.manager.StartPlugin(cCtx, testPluginID)
require.Nil(t, err)
require.Equal(t, 1, ctx.plugin.startCount)
var wg sync.WaitGroup
wg.Add(1)
go func() {
ctx.plugin.kill()
for {
if !ctx.plugin.Exited() {
break
}
}
wg.Done()
}()
wg.Wait()
require.Equal(t, 2, ctx.plugin.startCount)
})
})
})
})
newManagerScenario(t, true, func(t *testing.T, ctx *managerScenarioCtx) {
t.Run("Plugin registration scenario when Grafana is licensed", func(t *testing.T) {
ctx.license.edition = "Enterprise"
ctx.license.hasLicense = true
ctx.license.tokenRaw = "testtoken"
ctx.cfg.BuildVersion = "7.0.0"
ctx.cfg.EnterpriseLicensePath = "/license.txt"
err := ctx.manager.RegisterAndStart(context.Background(), testPluginID, ctx.factory)
require.NoError(t, err)
t.Run("Should provide expected host environment variables", func(t *testing.T) {
require.Len(t, ctx.env, 9)
require.EqualValues(t, []string{
"GF_VERSION=7.0.0",
"GF_EDITION=Enterprise",
"GF_ENTERPRISE_LICENSE_PATH=/license.txt",
"GF_ENTERPRISE_LICENSE_TEXT=testtoken",
fmt.Sprintf("%s=true", awsds.AssumeRoleEnabledEnvVarKeyName),
fmt.Sprintf("%s=keys,credentials", awsds.AllowedAuthProvidersEnvVarKeyName),
"AZURE_CLOUD=AzureCloud",
"AZURE_MANAGED_IDENTITY_CLIENT_ID=client-id",
"AZURE_MANAGED_IDENTITY_ENABLED=true"},
ctx.env)
})
})
})
}
type managerScenarioCtx struct {
cfg *setting.Cfg
license *testLicensingService
manager *Manager
factory backendplugin.PluginFactoryFunc
plugin *testPlugin
env []string
}
func newManagerScenario(t *testing.T, managed bool, fn func(t *testing.T, ctx *managerScenarioCtx)) {
t.Helper()
cfg := setting.NewCfg()
cfg.AWSAllowedAuthProviders = []string{"keys", "credentials"}
cfg.AWSAssumeRoleEnabled = true
cfg.Azure.ManagedIdentityEnabled = true
cfg.Azure.Cloud = "AzureCloud"
cfg.Azure.ManagedIdentityClientId = "client-id"
license := &testLicensingService{}
validator := &testPluginRequestValidator{}
ctx := &managerScenarioCtx{
cfg: cfg,
license: license,
manager: &Manager{
Cfg: cfg,
License: license,
PluginRequestValidator: validator,
logger: log.New("test"),
plugins: map[string]backendplugin.Plugin{},
},
}
ctx.factory = func(pluginID string, logger log.Logger, env []string) (backendplugin.Plugin, error) {
ctx.plugin = &testPlugin{
pluginID: pluginID,
logger: logger,
managed: managed,
}
ctx.env = env
return ctx.plugin, nil
}
fn(t, ctx)
}
type testPlugin struct {
pluginID string
logger log.Logger
startCount int
stopCount int
managed bool
exited bool
decommissioned bool
backend.CollectMetricsHandlerFunc
backend.CheckHealthHandlerFunc
backend.QueryDataHandlerFunc
backend.CallResourceHandlerFunc
mutex sync.RWMutex
}
func (tp *testPlugin) PluginID() string {
return tp.pluginID
}
func (tp *testPlugin) Logger() log.Logger {
return tp.logger
}
func (tp *testPlugin) Start(ctx context.Context) error {
tp.mutex.Lock()
defer tp.mutex.Unlock()
tp.exited = false
tp.startCount++
return nil
}
func (tp *testPlugin) Stop(ctx context.Context) error {
tp.mutex.Lock()
defer tp.mutex.Unlock()
tp.stopCount++
return nil
}
func (tp *testPlugin) IsManaged() bool {
return tp.managed
}
func (tp *testPlugin) Exited() bool {
tp.mutex.RLock()
defer tp.mutex.RUnlock()
return tp.exited
}
func (tp *testPlugin) Decommission() error {
tp.mutex.Lock()
defer tp.mutex.Unlock()
tp.decommissioned = true
return nil
}
func (tp *testPlugin) IsDecommissioned() bool {
tp.mutex.RLock()
defer tp.mutex.RUnlock()
return tp.decommissioned
}
func (tp *testPlugin) kill() {
tp.mutex.Lock()
defer tp.mutex.Unlock()
tp.exited = true
}
func (tp *testPlugin) CollectMetrics(ctx context.Context) (*backend.CollectMetricsResult, error) {
if tp.CollectMetricsHandlerFunc != nil {
return tp.CollectMetricsHandlerFunc(ctx)
}
return nil, backendplugin.ErrMethodNotImplemented
}
func (tp *testPlugin) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
if tp.CheckHealthHandlerFunc != nil {
return tp.CheckHealthHandlerFunc(ctx, req)
}
return nil, backendplugin.ErrMethodNotImplemented
}
func (tp *testPlugin) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
if tp.QueryDataHandlerFunc != nil {
return tp.QueryDataHandlerFunc(ctx, req)
}
return nil, backendplugin.ErrMethodNotImplemented
}
func (tp *testPlugin) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
if tp.CallResourceHandlerFunc != nil {
return tp.CallResourceHandlerFunc(ctx, req, sender)
}
return backendplugin.ErrMethodNotImplemented
}
func (tp *testPlugin) SubscribeStream(ctx context.Context, request *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
return nil, backendplugin.ErrMethodNotImplemented
}
func (tp *testPlugin) PublishStream(ctx context.Context, request *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
return nil, backendplugin.ErrMethodNotImplemented
}
func (tp *testPlugin) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error {
return backendplugin.ErrMethodNotImplemented
}
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 testPluginRequestValidator struct{}
func (t *testPluginRequestValidator) Validate(string, *http.Request) error {
return nil
}
@@ -1,40 +0,0 @@
package manager
import (
"fmt"
"os"
"strings"
"github.com/grafana/grafana/pkg/setting"
)
type pluginSettings map[string]string
func (ps pluginSettings) ToEnv(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(plugID string, cfg *setting.Cfg) pluginSettings {
ps := pluginSettings{}
for k, v := range cfg.PluginSettings[plugID] {
if k == "path" || strings.ToLower(k) == "id" {
continue
}
ps[k] = v
}
return ps
}
@@ -1,96 +0,0 @@
package manager
import (
"os"
"sort"
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
func TestPluginSettings(t *testing.T) {
t.Run("Should only extract from sections beginning with 'plugin.' in config", func(t *testing.T) {
cfg := &setting.Cfg{
PluginSettings: setting.PluginSettings{
"plugin": map[string]string{
"key1": "value1",
"key2": "value2",
},
},
}
ps := getPluginSettings("plugin", cfg)
require.Len(t, ps, 2)
t.Run("Should skip path setting", func(t *testing.T) {
cfg.PluginSettings["plugin"]["path"] = "value"
ps := getPluginSettings("plugin", cfg)
require.Len(t, ps, 2)
})
t.Run("Should skip id setting", func(t *testing.T) {
cfg.PluginSettings["plugin"]["id"] = "value"
ps := getPluginSettings("plugin", cfg)
require.Len(t, ps, 2)
})
t.Run("Should return expected environment variables from plugin settings ", func(t *testing.T) {
ps := getPluginSettings("plugin", cfg)
env := ps.ToEnv("GF_PLUGIN", []string{"GF_VERSION=6.7.0"})
sort.Strings(env)
require.Len(t, env, 3)
require.EqualValues(t, []string{"GF_PLUGIN_KEY1=value1", "GF_PLUGIN_KEY2=value2", "GF_VERSION=6.7.0"}, env)
})
t.Run("Should override config variable with environment variable ", func(t *testing.T) {
_ = os.Setenv("GF_PLUGIN_KEY1", "sth")
t.Cleanup(func() {
_ = os.Unsetenv("GF_PLUGIN_KEY1")
})
ps := getPluginSettings("plugin", cfg)
env := ps.ToEnv("GF_PLUGIN", []string{"GF_VERSION=6.7.0"})
sort.Strings(env)
require.Len(t, env, 3)
require.EqualValues(t, []string{"GF_PLUGIN_KEY1=sth", "GF_PLUGIN_KEY2=value2", "GF_VERSION=6.7.0"}, env)
})
t.Run("Config variable doesn't match env variable ", func(t *testing.T) {
_ = os.Setenv("GF_PLUGIN_KEY3", "value3")
t.Cleanup(func() {
_ = os.Unsetenv("GF_PLUGIN_KEY3")
})
ps := getPluginSettings("plugin", cfg)
env := ps.ToEnv("GF_PLUGIN", []string{"GF_VERSION=6.7.0"})
sort.Strings(env)
require.Len(t, env, 3)
require.EqualValues(t, []string{"GF_PLUGIN_KEY1=value1", "GF_PLUGIN_KEY2=value2", "GF_VERSION=6.7.0"}, env)
})
t.Run("Should override missing config variable with environment variable ", func(t *testing.T) {
cfg := &setting.Cfg{
PluginSettings: setting.PluginSettings{
"plugin": map[string]string{
"key1": "value1",
"key2": "",
},
},
}
ps := getPluginSettings("plugin", cfg)
require.Len(t, ps, 2)
_ = os.Setenv("GF_PLUGIN_KEY2", "sth")
t.Cleanup(func() {
_ = os.Unsetenv("GF_PLUGIN_KEY1")
})
env := ps.ToEnv("GF_PLUGIN", []string{"GF_VERSION=6.7.0"})
sort.Strings(env)
require.Len(t, env, 3)
require.EqualValues(t, []string{"GF_PLUGIN_KEY1=value1", "GF_PLUGIN_KEY2=sth", "GF_VERSION=6.7.0"}, env)
})
})
}
@@ -1,57 +0,0 @@
package manager
import (
"context"
"errors"
"io"
"github.com/grafana/grafana-plugin-sdk-go/backend"
)
func newCallResourceResponseStream(ctx context.Context) *callResourceResponseStream {
return &callResourceResponseStream{
ctx: ctx,
stream: make(chan *backend.CallResourceResponse),
}
}
type callResourceResponseStream struct {
ctx context.Context
stream chan *backend.CallResourceResponse
closed bool
}
func (s *callResourceResponseStream) Send(res *backend.CallResourceResponse) error {
if s.closed {
return errors.New("cannot send to a closed stream")
}
select {
case <-s.ctx.Done():
return errors.New("cancelled")
case s.stream <- res:
return nil
}
}
func (s *callResourceResponseStream) Recv() (*backend.CallResourceResponse, error) {
select {
case <-s.ctx.Done():
return nil, s.ctx.Err()
case res, ok := <-s.stream:
if !ok {
return nil, io.EOF
}
return res, nil
}
}
func (s *callResourceResponseStream) Close() error {
if s.closed {
return errors.New("cannot close a closed stream")
}
close(s.stream)
s.closed = true
return nil
}
-50
View File
@@ -1,50 +0,0 @@
package plugins
import (
"context"
"encoding/json"
"path/filepath"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin"
"github.com/grafana/grafana/pkg/util/errutil"
)
// DataSourcePlugin contains all metadata about a datasource plugin
type DataSourcePlugin struct {
FrontendPluginBase
Annotations bool `json:"annotations"`
Metrics bool `json:"metrics"`
Alerting bool `json:"alerting"`
Explore bool `json:"explore"`
Table bool `json:"tables"`
Logs bool `json:"logs"`
Tracing bool `json:"tracing"`
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
BuiltIn bool `json:"builtIn,omitempty"`
Mixed bool `json:"mixed,omitempty"`
Routes []*AppPluginRoute `json:"routes"`
Streaming bool `json:"streaming"`
Backend bool `json:"backend,omitempty"`
Executable string `json:"executable,omitempty"`
SDK bool `json:"sdk,omitempty"`
}
func (p *DataSourcePlugin) Load(decoder *json.Decoder, base *PluginBase, backendPluginManager backendplugin.Manager) (
interface{}, error) {
if err := decoder.Decode(p); err != nil {
return nil, errutil.Wrapf(err, "Failed to decode datasource plugin")
}
if p.Backend {
cmd := ComposePluginStartCommand(p.Executable)
fullpath := filepath.Join(base.PluginDir, cmd)
factory := grpcplugin.NewBackendPlugin(p.Id, fullpath)
if err := backendPluginManager.RegisterAndStart(context.Background(), p.Id, factory); err != nil {
return nil, errutil.Wrapf(err, "failed to register backend plugin")
}
}
return p, nil
}
-8
View File
@@ -1,8 +0,0 @@
package plugins
type ErrorCode string
type PluginError struct {
ErrorCode `json:"errorCode"`
PluginID string `json:"pluginId,omitempty"`
}
-102
View File
@@ -1,102 +0,0 @@
package plugins
import (
"net/url"
"path"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
type FrontendPluginBase struct {
PluginBase
}
func (fp *FrontendPluginBase) InitFrontendPlugin(cfg *setting.Cfg) []*PluginStaticRoute {
var staticRoutes []*PluginStaticRoute
if isExternalPlugin(fp.PluginDir, cfg) {
staticRoutes = []*PluginStaticRoute{
{
Directory: fp.PluginDir,
PluginId: fp.Id,
},
}
}
fp.handleModuleDefaults(cfg)
fp.Info.Logos.Small = getPluginLogoUrl(fp.Type, fp.Info.Logos.Small, fp.BaseUrl)
fp.Info.Logos.Large = getPluginLogoUrl(fp.Type, fp.Info.Logos.Large, fp.BaseUrl)
for i := 0; i < len(fp.Info.Screenshots); i++ {
fp.Info.Screenshots[i].Path = evalRelativePluginUrlPath(fp.Info.Screenshots[i].Path, fp.BaseUrl, fp.Type)
}
return staticRoutes
}
func getPluginLogoUrl(pluginType, path, baseUrl string) string {
if path == "" {
return defaultLogoPath(pluginType)
}
return evalRelativePluginUrlPath(path, baseUrl, pluginType)
}
func defaultLogoPath(pluginType string) string {
return "public/img/icn-" + pluginType + ".svg"
}
func (fp *FrontendPluginBase) setPathsBasedOnApp(app *AppPlugin, cfg *setting.Cfg) {
appSubPath := strings.ReplaceAll(strings.Replace(fp.PluginDir, app.PluginDir, "", 1), "\\", "/")
fp.IncludedInAppId = app.Id
fp.BaseUrl = app.BaseUrl
if isExternalPlugin(app.PluginDir, cfg) {
fp.Module = util.JoinURLFragments("plugins/"+app.Id, appSubPath) + "/module"
} else {
fp.Module = util.JoinURLFragments("app/plugins/app/"+app.Id, appSubPath) + "/module"
}
}
func (fp *FrontendPluginBase) handleModuleDefaults(cfg *setting.Cfg) {
if isExternalPlugin(fp.PluginDir, cfg) {
fp.Module = path.Join("plugins", fp.Id, "module")
fp.BaseUrl = path.Join("public/plugins", fp.Id)
return
}
fp.IsCorePlugin = true
// Previously there was an assumption that the plugin directory
// should be public/app/plugins/<plugin type>/<plugin id>
// However this can be an issue if the plugin directory should be renamed to something else
currentDir := filepath.Base(fp.PluginDir)
// use path package for the following statements
// because these are not file paths
fp.Module = path.Join("app/plugins", fp.Type, currentDir, "module")
fp.BaseUrl = path.Join("public/app/plugins", fp.Type, currentDir)
}
func isExternalPlugin(pluginDir string, cfg *setting.Cfg) bool {
return !strings.Contains(pluginDir, cfg.StaticRootPath)
}
func evalRelativePluginUrlPath(pathStr, baseUrl, pluginType string) 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)
}
-34
View File
@@ -1,34 +0,0 @@
package plugins
import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
func TestFrontendPlugin(t *testing.T) {
t.Run("When setting paths based on App on Windows", func(t *testing.T) {
cfg := setting.NewCfg()
cfg.StaticRootPath = "c:\\grafana\\public"
fp := &FrontendPluginBase{
PluginBase: PluginBase{
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata\\datasources\\datasource",
BaseUrl: "fpbase",
},
}
app := &AppPlugin{
FrontendPluginBase: FrontendPluginBase{
PluginBase: PluginBase{
PluginDir: "c:\\grafana\\public\\app\\plugins\\app\\testdata",
Id: "testdata",
BaseUrl: "public/app/plugins/app/testdata",
},
},
}
fp.setPathsBasedOnApp(app, cfg)
require.Equal(t, "app/plugins/app/testdata/datasources/datasource/module", fp.Module)
})
}
+88 -75
View File
@@ -3,60 +3,102 @@ package plugins
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
)
// Manager is the plugin manager service interface.
type Manager interface {
// Renderer gets the renderer plugin.
Renderer() *RendererPlugin
// GetDataSource gets a data source plugin with a certain ID.
GetDataSource(id string) *DataSourcePlugin
// GetPlugin gets a plugin with a certain ID.
GetPlugin(id string) *PluginBase
// GetApp gets an app plugin with a certain ID.
GetApp(id string) *AppPlugin
// DataSourceCount gets the number of data sources.
DataSourceCount() int
// DataSources gets all data sources.
DataSources() []*DataSourcePlugin
// Apps gets all app plugins.
Apps() []*AppPlugin
// PanelCount gets the number of panels.
PanelCount() int
// AppCount gets the number of apps.
AppCount() int
// GetEnabledPlugins gets enabled plugins.
GetEnabledPlugins(orgID int64) (*EnabledPlugins, error)
// GrafanaLatestVersion gets the latest Grafana version.
GrafanaLatestVersion() string
// GrafanaHasUpdate returns whether Grafana has an update.
GrafanaHasUpdate() bool
// Plugins gets all plugins.
Plugins() []*PluginBase
// StaticRoutes gets all static routes.
StaticRoutes() []*PluginStaticRoute
// GetPluginSettings gets settings for a certain plugin.
GetPluginSettings(orgID int64) (map[string]*models.PluginSettingInfoDTO, error)
// DataRequestHandler is a data request handler interface.
type DataRequestHandler interface {
// HandleRequest handles a data request.
HandleRequest(context.Context, *models.DataSource, DataQuery) (DataResponse, error)
}
// Store is the storage for plugins.
type Store interface {
// Plugin finds a plugin by its ID.
Plugin(pluginID string) *Plugin
// Plugins returns plugins by their requested type.
Plugins(pluginTypes ...Type) []*Plugin
// Add adds a plugin to the store.
Add(ctx context.Context, pluginID, version string, opts AddOpts) error
// Remove removes a plugin from the store.
Remove(ctx context.Context, pluginID string) error
}
type AddOpts struct {
PluginInstallDir, PluginZipURL, PluginRepoURL string
}
// Loader is responsible for loading plugins from the file system.
type Loader interface {
// Load will return a list of plugins found in the provided file system paths.
Load(paths []string, ignore map[string]struct{}) ([]*Plugin, error)
// LoadWithFactory will return a plugin found in the provided file system path and use the provided factory to
// construct the plugin backend client.
LoadWithFactory(path string, factory backendplugin.PluginFactoryFunc) (*Plugin, error)
}
// Installer is responsible for managing plugins (add / remove) on the file system.
type Installer interface {
// Install downloads the requested plugin in the provided file system location.
Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error
// Uninstall removes the requested plugin from the provided file system location.
Uninstall(ctx context.Context, pluginDir string) error
// GetUpdateInfo provides update information for the requested plugin.
GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (UpdateInfo, error)
}
type UpdateInfo struct {
PluginZipURL string
}
// Client is used to communicate with backend plugin implementations.
type Client interface {
backend.QueryDataHandler
backend.CheckHealthHandler
// CallResource calls a plugin resource.
CallResource(pCtx backend.PluginContext, ctx *models.ReqContext, path string)
// CollectMetrics collects metrics from a plugin.
CollectMetrics(ctx context.Context, pluginID string) (*backend.CollectMetricsResult, error)
}
type RendererManager interface {
// Renderer returns a renderer plugin.
Renderer() *Plugin
}
type CoreBackendRegistrar interface {
// LoadAndRegister loads and registers a Core backend plugin
LoadAndRegister(pluginID string, factory backendplugin.PluginFactoryFunc) error
}
type StaticRouteResolver interface {
Routes() []*StaticRoute
}
type ErrorResolver interface {
PluginErrors() []*Error
}
type PluginLoaderAuthorizer interface {
// CanLoadPlugin confirms if a plugin is authorized to load
CanLoadPlugin(plugin *Plugin) bool
}
type PluginDashboardManager interface {
// GetPluginDashboards gets dashboards for a certain org/plugin.
GetPluginDashboards(orgID int64, pluginID string) ([]*PluginDashboardInfoDTO, error)
// GetPluginMarkdown gets markdown for a certain plugin/name.
GetPluginMarkdown(pluginID string, name string) ([]byte, error)
// ImportDashboard imports a dashboard.
ImportDashboard(pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json,
overwrite bool, inputs []ImportDashboardInput, user *models.SignedInUser,
requestHandler DataRequestHandler) (PluginDashboardInfoDTO, *models.Dashboard, error)
// ScanningErrors returns plugin scanning errors encountered.
ScanningErrors() []PluginError
// LoadPluginDashboard loads a plugin dashboard.
LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error)
// IsAppInstalled returns whether an app is installed.
IsAppInstalled(id string) bool
// Install installs a plugin.
Install(ctx context.Context, pluginID, version string) error
// Uninstall uninstalls a plugin.
Uninstall(ctx context.Context, pluginID string) error
// ImportDashboard imports a dashboard.
ImportDashboard(pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json,
overwrite bool, inputs []ImportDashboardInput, user *models.SignedInUser) (PluginDashboardInfoDTO,
*models.Dashboard, error)
}
type ImportDashboardInput struct {
@@ -65,32 +107,3 @@ type ImportDashboardInput struct {
Name string `json:"name"`
Value string `json:"value"`
}
// DataRequestHandler is a data request handler interface.
type DataRequestHandler interface {
// HandleRequest handles a data request.
HandleRequest(context.Context, *models.DataSource, DataQuery) (DataResponse, error)
}
type PluginInstaller interface {
// Install finds the plugin given the provided information and installs in the provided plugins directory.
Install(ctx context.Context, pluginID, version, pluginsDirectory, pluginZipURL, pluginRepoURL string) error
// Uninstall removes the specified plugin from the provided plugins directory.
Uninstall(ctx context.Context, pluginPath string) error
// GetUpdateInfo returns update information if the requested plugin is supported on the running system.
GetUpdateInfo(pluginID, version, pluginRepoURL string) (UpdateInfo, error)
}
type PluginInstallerLogger interface {
Successf(format string, args ...interface{})
Failuref(format string, args ...interface{})
Info(args ...interface{})
Infof(format string, args ...interface{})
Debug(args ...interface{})
Debugf(format string, args ...interface{})
Warn(args ...interface{})
Warnf(format string, args ...interface{})
Error(args ...interface{})
Errorf(format string, args ...interface{})
}
-61
View File
@@ -6,9 +6,7 @@ import (
"regexp"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboards"
)
var varRegex = regexp.MustCompile(`(\$\{.+?\})`)
@@ -21,65 +19,6 @@ func (e DashboardInputMissingError) Error() string {
return fmt.Sprintf("Dashboard input variable: %v missing from import command", e.VariableName)
}
func (pm *PluginManager) ImportDashboard(pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json,
overwrite bool, inputs []plugins.ImportDashboardInput, user *models.SignedInUser,
requestHandler plugins.DataRequestHandler) (plugins.PluginDashboardInfoDTO, *models.Dashboard, error) {
var dashboard *models.Dashboard
if pluginID != "" {
var err error
if dashboard, err = pm.LoadPluginDashboard(pluginID, path); err != nil {
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
}
} else {
dashboard = models.NewDashboardFromJson(dashboardModel)
}
evaluator := &DashTemplateEvaluator{
template: dashboard.Data,
inputs: inputs,
}
generatedDash, err := evaluator.Eval()
if err != nil {
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
}
saveCmd := models.SaveDashboardCommand{
Dashboard: generatedDash,
OrgId: orgID,
UserId: user.UserId,
Overwrite: overwrite,
PluginId: pluginID,
FolderId: folderID,
}
dto := &dashboards.SaveDashboardDTO{
OrgId: orgID,
Dashboard: saveCmd.GetDashboardModel(),
Overwrite: saveCmd.Overwrite,
User: user,
}
savedDash, err := dashboards.NewService(pm.SQLStore).ImportDashboard(dto)
if err != nil {
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
}
return plugins.PluginDashboardInfoDTO{
PluginId: pluginID,
Title: savedDash.Title,
Path: path,
Revision: savedDash.Data.Get("revision").MustInt64(1),
FolderId: savedDash.FolderId,
ImportedUri: "db/" + savedDash.Slug,
ImportedUrl: savedDash.GetUrl(),
ImportedRevision: dashboard.Data.Get("revision").MustInt64(1),
Imported: true,
DashboardId: savedDash.Id,
Slug: savedDash.Slug,
}, savedDash, nil
}
type DashTemplateEvaluator struct {
template *simplejson.Json
inputs []plugins.ImportDashboardInput
+4 -2
View File
@@ -7,6 +7,8 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
@@ -25,7 +27,7 @@ func TestDashboardImport(t *testing.T) {
info, dash, err := pm.ImportDashboard("test-app", "dashboards/connections.json", 1, 0, nil, false,
[]plugins.ImportDashboardInput{
{Name: "*", Type: "datasource", Value: "graphite"},
}, &models.SignedInUser{UserId: 1, OrgRole: models.ROLE_ADMIN}, nil)
}, &models.SignedInUser{UserId: 1, OrgRole: models.ROLE_ADMIN})
require.NoError(t, err)
require.NotNil(t, info)
require.NotNil(t, dash)
@@ -88,7 +90,7 @@ func pluginScenario(t *testing.T, desc string, fn func(*testing.T, *PluginManage
},
},
}
pm := newManager(cfg, &sqlstore.SQLStore{}, &fakeBackendPluginManager{})
pm := newManager(cfg, nil, loader.New(nil, cfg, &signature.UnsignedPluginAuthorizer{Cfg: cfg}), &sqlstore.SQLStore{})
err := pm.init()
require.NoError(t, err)
+70 -10
View File
@@ -8,12 +8,13 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboards"
)
func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
plugin := pm.GetPlugin(pluginID)
func (m *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
plugin := m.Plugin(pluginID)
if plugin == nil {
return nil, plugins.PluginNotFoundError{PluginID: pluginID}
return nil, plugins.NotFoundError{PluginID: pluginID}
}
result := make([]*plugins.PluginDashboardInfoDTO, 0)
@@ -26,18 +27,18 @@ func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*p
existingMatches := make(map[int64]bool)
for _, include := range plugin.Includes {
if include.Type != plugins.PluginTypeDashboard {
if include.Type != plugins.TypeDashboard {
continue
}
dashboard, err := pm.LoadPluginDashboard(plugin.Id, include.Path)
dashboard, err := m.LoadPluginDashboard(plugin.ID, include.Path)
if err != nil {
return nil, err
}
res := &plugins.PluginDashboardInfoDTO{}
res.Path = include.Path
res.PluginId = plugin.Id
res.PluginId = plugin.ID
res.Title = dashboard.Title
res.Revision = dashboard.Data.Get("revision").MustInt64(1)
@@ -70,10 +71,10 @@ func (pm *PluginManager) GetPluginDashboards(orgID int64, pluginID string) ([]*p
return result, nil
}
func (pm *PluginManager) LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error) {
plugin := pm.GetPlugin(pluginID)
func (m *PluginManager) LoadPluginDashboard(pluginID, path string) (*models.Dashboard, error) {
plugin := m.Plugin(pluginID)
if plugin == nil {
return nil, plugins.PluginNotFoundError{PluginID: pluginID}
return nil, plugins.NotFoundError{PluginID: pluginID}
}
dashboardFilePath := filepath.Join(plugin.PluginDir, path)
@@ -88,7 +89,7 @@ func (pm *PluginManager) LoadPluginDashboard(pluginID, path string) (*models.Das
defer func() {
if err := reader.Close(); err != nil {
plog.Warn("Failed to close file", "path", dashboardFilePath, "err", err)
m.log.Warn("Failed to close file", "path", dashboardFilePath, "err", err)
}
}()
@@ -99,3 +100,62 @@ func (pm *PluginManager) LoadPluginDashboard(pluginID, path string) (*models.Das
return models.NewDashboardFromJson(data), nil
}
func (m *PluginManager) ImportDashboard(pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json,
overwrite bool, inputs []plugins.ImportDashboardInput, user *models.SignedInUser) (plugins.PluginDashboardInfoDTO,
*models.Dashboard, error) {
var dashboard *models.Dashboard
if pluginID != "" {
var err error
if dashboard, err = m.LoadPluginDashboard(pluginID, path); err != nil {
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
}
} else {
dashboard = models.NewDashboardFromJson(dashboardModel)
}
evaluator := &DashTemplateEvaluator{
template: dashboard.Data,
inputs: inputs,
}
generatedDash, err := evaluator.Eval()
if err != nil {
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
}
saveCmd := models.SaveDashboardCommand{
Dashboard: generatedDash,
OrgId: orgID,
UserId: user.UserId,
Overwrite: overwrite,
PluginId: pluginID,
FolderId: folderID,
}
dto := &dashboards.SaveDashboardDTO{
OrgId: orgID,
Dashboard: saveCmd.GetDashboardModel(),
Overwrite: saveCmd.Overwrite,
User: user,
}
savedDash, err := dashboards.NewService(m.sqlStore).ImportDashboard(dto)
if err != nil {
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
}
return plugins.PluginDashboardInfoDTO{
PluginId: pluginID,
Title: savedDash.Title,
Path: path,
Revision: savedDash.Data.Get("revision").MustInt64(1),
FolderId: savedDash.FolderId,
ImportedUri: "db/" + savedDash.Slug,
ImportedUrl: savedDash.GetUrl(),
ImportedRevision: dashboard.Data.Get("revision").MustInt64(1),
Imported: true,
DashboardId: savedDash.Id,
Slug: savedDash.Slug,
}, savedDash, nil
}
+4 -3
View File
@@ -4,11 +4,12 @@ import (
"context"
"testing"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
@@ -22,7 +23,7 @@ func TestGetPluginDashboards(t *testing.T) {
},
},
}
pm := newManager(cfg, &sqlstore.SQLStore{}, &fakeBackendPluginManager{})
pm := newManager(cfg, nil, loader.New(nil, cfg, &signature.UnsignedPluginAuthorizer{Cfg: cfg}), &sqlstore.SQLStore{})
err := pm.init()
require.NoError(t, err)
-11
View File
@@ -1,11 +0,0 @@
package manager
import (
"github.com/grafana/grafana/pkg/plugins"
)
const (
signatureMissing plugins.ErrorCode = "signatureMissing"
signatureModified plugins.ErrorCode = "signatureModified"
signatureInvalid plugins.ErrorCode = "signatureInvalid"
)
+15
View File
@@ -0,0 +1,15 @@
package installer
type Logger interface {
Successf(format string, args ...interface{})
Failuref(format string, args ...interface{})
Info(args ...interface{})
Infof(format string, args ...interface{})
Debug(args ...interface{})
Debugf(format string, args ...interface{})
Warn(args ...interface{})
Warnf(format string, args ...interface{})
Error(args ...interface{})
Errorf(format string, args ...interface{})
}
+3 -3
View File
@@ -33,7 +33,7 @@ type Installer struct {
httpClient http.Client
httpClientNoTimeout http.Client
grafanaVersion string
log plugins.PluginInstallerLogger
log Logger
}
const (
@@ -80,7 +80,7 @@ func (e ErrVersionNotFound) Error() string {
return fmt.Sprintf("%s v%s either does not exist or is not supported on your system (%s)", e.PluginID, e.RequestedVersion, e.SystemInfo)
}
func New(skipTLSVerify bool, grafanaVersion string, logger plugins.PluginInstallerLogger) *Installer {
func New(skipTLSVerify bool, grafanaVersion string, logger Logger) plugins.Installer {
return &Installer{
httpClient: makeHttpClient(skipTLSVerify, 10*time.Second),
httpClientNoTimeout: makeHttpClient(skipTLSVerify, 0),
@@ -410,7 +410,7 @@ func normalizeVersion(version string) string {
return normalized
}
func (i *Installer) GetUpdateInfo(pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error) {
func (i *Installer) GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error) {
plugin, err := i.getPluginMetadataFromPluginRepo(pluginID, pluginRepoURL)
if err != nil {
return plugins.UpdateInfo{}, err
@@ -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}
}
+1 -1
View File
@@ -12,7 +12,7 @@ type InfraLogWrapper struct {
debugMode bool
}
func NewInstallerLogger(name string, debugMode bool) (l *InfraLogWrapper) {
func newInstallerLogger(name string, debugMode bool) (l *InfraLogWrapper) {
return &InfraLogWrapper{
debugMode: debugMode,
l: log.New(name),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
package manager
import (
"path/filepath"
"strings"
"testing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
func TestPluginManager_int_init(t *testing.T) {
t.Helper()
staticRootPath, err := filepath.Abs("../../../public/")
require.NoError(t, err)
bundledPluginsPath, err := filepath.Abs("../../../plugins-bundled/internal")
require.NoError(t, err)
cfg := &setting.Cfg{
Raw: ini.Empty(),
Env: setting.Prod,
StaticRootPath: staticRootPath,
BundledPluginsPath: bundledPluginsPath,
PluginSettings: map[string]map[string]string{
"plugin.datasource-id": {
"path": "testdata/test-app",
},
},
}
license := &licensing.OSSLicensingService{
Cfg: cfg,
}
pm := newManager(cfg, nil, loader.New(license, cfg, &signature.UnsignedPluginAuthorizer{Cfg: cfg}), nil)
err = pm.init()
require.NoError(t, err)
verifyCorePluginCatalogue(t, pm)
verifyBundledPlugins(t, pm)
verifyPluginStaticRoutes(t, pm)
}
func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
t.Helper()
expPanels := map[string]struct{}{
"alertGroups": {},
"alertlist": {},
"annolist": {},
"barchart": {},
"bargauge": {},
"canvas": {},
"dashlist": {},
"debug": {},
"gauge": {},
"geomap": {},
"gettingstarted": {},
"graph": {},
"heatmap": {},
"histogram": {},
"icon": {},
"live": {},
"logs": {},
"news": {},
"nodeGraph": {},
"piechart": {},
"pluginlist": {},
"stat": {},
"state-timeline": {},
"status-history": {},
"table": {},
"table-old": {},
"text": {},
"timeseries": {},
"welcome": {},
"xychart": {},
}
expDataSources := map[string]struct{}{
"alertmanager": {},
"dashboard": {},
"input": {},
"jaeger": {},
"mixed": {},
"zipkin": {},
}
expApps := map[string]struct{}{
"test-app": {},
}
panels := pm.Plugins(plugins.Panel)
assert.Equal(t, len(expPanels), len(panels))
for _, p := range panels {
require.NotNil(t, pm.Plugin(p.ID))
assert.Contains(t, expPanels, p.ID)
assert.Contains(t, pm.registeredPlugins(), p.ID)
}
dataSources := pm.Plugins(plugins.DataSource)
assert.Equal(t, len(expDataSources), len(dataSources))
for _, ds := range dataSources {
require.NotNil(t, pm.Plugin(ds.ID))
assert.Contains(t, expDataSources, ds.ID)
assert.Contains(t, pm.registeredPlugins(), ds.ID)
}
apps := pm.Plugins(plugins.App)
assert.Equal(t, len(expApps), len(apps))
for _, app := range apps {
require.NotNil(t, pm.Plugin(app.ID))
require.Contains(t, expApps, app.ID)
assert.Contains(t, pm.registeredPlugins(), app.ID)
}
assert.Equal(t, len(expPanels)+len(expDataSources)+len(expApps), len(pm.Plugins()))
}
func verifyBundledPlugins(t *testing.T, pm *PluginManager) {
t.Helper()
dsPlugins := make(map[string]struct{})
for _, p := range pm.Plugins(plugins.DataSource) {
dsPlugins[p.ID] = struct{}{}
}
pluginRoutes := make(map[string]*plugins.StaticRoute)
for _, r := range pm.Routes() {
pluginRoutes[r.PluginID] = r
}
assert.NotNil(t, pm.Plugin("input"))
assert.NotNil(t, dsPlugins["input"])
for _, pluginID := range []string{"input"} {
assert.Contains(t, pluginRoutes, pluginID)
assert.True(t, strings.HasPrefix(pluginRoutes[pluginID].Directory, pm.Plugin("input").PluginDir))
}
}
func verifyPluginStaticRoutes(t *testing.T, pm *PluginManager) {
pluginRoutes := make(map[string]*plugins.StaticRoute)
for _, route := range pm.Routes() {
pluginRoutes[route.PluginID] = route
}
assert.Len(t, pluginRoutes, 2)
assert.Contains(t, pluginRoutes, "input")
assert.Equal(t, pluginRoutes["input"].Directory, pm.Plugin("input").PluginDir)
assert.Contains(t, pluginRoutes, "test-app")
assert.Equal(t, pluginRoutes["test-app"].Directory, pm.Plugin("test-app").PluginDir)
}
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
package manager
import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
)
func (pm *PluginManager) GetPluginSettings(orgID int64) (map[string]*models.PluginSettingInfoDTO, error) {
pluginSettings, err := pm.SQLStore.GetPluginSettings(orgID)
if err != nil {
return nil, err
}
pluginMap := make(map[string]*models.PluginSettingInfoDTO)
for _, plug := range pluginSettings {
pluginMap[plug.PluginId] = plug
}
for _, pluginDef := range pm.Plugins() {
// ignore entries that exists
if _, ok := pluginMap[pluginDef.Id]; ok {
continue
}
// default to enabled true
opt := &models.PluginSettingInfoDTO{
PluginId: pluginDef.Id,
OrgId: orgID,
Enabled: true,
}
// apps are disabled by default unless autoEnabled: true
if app, exists := pm.apps[pluginDef.Id]; exists {
opt.Enabled = app.AutoEnabled
opt.Pinned = app.AutoEnabled
}
// if it's included in app check app settings
if pluginDef.IncludedInAppId != "" {
// app components are by default disabled
opt.Enabled = false
if appSettings, ok := pluginMap[pluginDef.IncludedInAppId]; ok {
opt.Enabled = appSettings.Enabled
}
}
pluginMap[pluginDef.Id] = opt
}
return pluginMap, nil
}
func (pm *PluginManager) GetEnabledPlugins(orgID int64) (*plugins.EnabledPlugins, error) {
enabledPlugins := &plugins.EnabledPlugins{
Panels: make([]*plugins.PanelPlugin, 0),
DataSources: make(map[string]*plugins.DataSourcePlugin),
Apps: make([]*plugins.AppPlugin, 0),
}
pluginSettingMap, err := pm.GetPluginSettings(orgID)
if err != nil {
return enabledPlugins, err
}
for _, app := range pm.Apps() {
if b, ok := pluginSettingMap[app.Id]; ok {
app.Pinned = b.Pinned
enabledPlugins.Apps = append(enabledPlugins.Apps, app)
}
}
// add all plugins that are not part of an App.
for dsID, ds := range pm.dataSources {
if _, exists := pluginSettingMap[ds.Id]; exists {
enabledPlugins.DataSources[dsID] = ds
}
}
for _, panel := range pm.panels {
if _, exists := pluginSettingMap[panel.Id]; exists {
enabledPlugins.Panels = append(enabledPlugins.Panels, panel)
}
}
return enabledPlugins, nil
}
// IsAppInstalled checks if an app plugin with provided plugin ID is installed.
func (pm *PluginManager) IsAppInstalled(pluginID string) bool {
_, exists := pm.apps[pluginID]
return exists
}
@@ -0,0 +1,34 @@
package signature
import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
)
func ProvideService(cfg *setting.Cfg) (*UnsignedPluginAuthorizer, error) {
return &UnsignedPluginAuthorizer{
Cfg: cfg,
}, nil
}
type UnsignedPluginAuthorizer struct {
Cfg *setting.Cfg
}
func (u *UnsignedPluginAuthorizer) CanLoadPlugin(p *plugins.Plugin) bool {
if p.Signature != plugins.SignatureUnsigned {
return true
}
if u.Cfg.Env == setting.Dev {
return true
}
for _, pID := range u.Cfg.PluginsAllowUnsigned {
if pID == p.ID {
return true
}
}
return false
}
@@ -1,4 +1,4 @@
package manager
package signature
import (
"bytes"
@@ -15,13 +15,13 @@ import (
"path/filepath"
"strings"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/clearsign"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/clearsign"
)
// Soon we can fetch keys from:
@@ -59,11 +59,11 @@ type pluginManifest struct {
Files map[string]string `json:"files"`
// V2 supported fields
ManifestVersion string `json:"manifestVersion"`
SignatureType plugins.PluginSignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
ManifestVersion string `json:"manifestVersion"`
SignatureType plugins.SignatureType `json:"signatureType"`
SignedByOrg string `json:"signedByOrg"`
SignedByOrgName string `json:"signedByOrgName"`
RootURLs []string `json:"rootUrls"`
}
func (m *pluginManifest) isV2() bool {
@@ -99,9 +99,13 @@ func readPluginManifest(body []byte) (*pluginManifest, error) {
return manifest, nil
}
// getPluginSignatureState returns the signature state for a plugin.
func getPluginSignatureState(log log.Logger, plugin *plugins.PluginBase) (plugins.PluginSignatureState, error) {
log.Debug("Getting signature state of plugin", "plugin", plugin.Id, "isBackend", plugin.Backend)
func Calculate(log log.Logger, plugin *plugins.Plugin) (plugins.Signature, error) {
if plugin.IsCorePlugin() {
return plugins.Signature{
Status: plugins.SignatureInternal,
}, nil
}
manifestPath := filepath.Join(plugin.PluginDir, "MANIFEST.txt")
// nolint:gosec
@@ -109,62 +113,55 @@ func getPluginSignatureState(log log.Logger, plugin *plugins.PluginBase) (plugin
// on plugin the folder structure on disk and not user input.
byteValue, err := ioutil.ReadFile(manifestPath)
if err != nil || len(byteValue) < 10 {
log.Debug("Plugin is unsigned", "id", plugin.Id)
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureUnsigned,
log.Debug("Plugin is unsigned", "id", plugin.ID)
return plugins.Signature{
Status: plugins.SignatureUnsigned,
}, nil
}
manifest, err := readPluginManifest(byteValue)
if err != nil {
log.Debug("Plugin signature invalid", "id", plugin.Id)
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureInvalid,
log.Debug("Plugin signature invalid", "id", plugin.ID)
return plugins.Signature{
Status: plugins.SignatureInvalid,
}, nil
}
// Make sure the versions all match
if manifest.Plugin != plugin.Id || manifest.Version != plugin.Info.Version {
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureModified,
if manifest.Plugin != plugin.ID || manifest.Version != plugin.Info.Version {
return plugins.Signature{
Status: plugins.SignatureModified,
}, nil
}
// Validate that private is running within defined root URLs
if manifest.SignatureType == plugins.PrivateType {
if manifest.SignatureType == plugins.PrivateSignature {
appURL, err := url.Parse(setting.AppUrl)
if err != nil {
return plugins.PluginSignatureState{}, err
return plugins.Signature{}, err
}
appSubURL, err := url.Parse(setting.AppSubUrl)
if err != nil {
return plugins.PluginSignatureState{}, err
}
appURLPath := path.Join(appSubURL.RequestURI(), appURL.RequestURI())
foundMatch := false
for _, u := range manifest.RootURLs {
rootURL, err := url.Parse(u)
if err != nil {
log.Warn("Could not parse plugin root URL", "plugin", plugin.Id, "rootUrl", rootURL)
return plugins.PluginSignatureState{}, err
log.Warn("Could not parse plugin root URL", "plugin", plugin.ID, "rootUrl", rootURL)
return plugins.Signature{}, err
}
if rootURL.Scheme == appURL.Scheme &&
rootURL.Host == appURL.Host {
foundMatch = path.Clean(rootURL.RequestURI()) == appURLPath
if foundMatch {
break
}
rootURL.Host == appURL.Host &&
path.Clean(rootURL.RequestURI()) == path.Clean(appURL.RequestURI()) {
foundMatch = true
break
}
}
if !foundMatch {
log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.Id,
log.Warn("Could not find root URL that matches running application URL", "plugin", plugin.ID,
"appUrl", appURL, "rootUrls", manifest.RootURLs)
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureInvalid,
return plugins.Signature{
Status: plugins.SignatureInvalid,
}, nil
}
}
@@ -172,24 +169,23 @@ func getPluginSignatureState(log log.Logger, plugin *plugins.PluginBase) (plugin
manifestFiles := make(map[string]struct{}, len(manifest.Files))
// Verify the manifest contents
log.Debug("Verifying contents of plugin manifest", "plugin", plugin.Id)
for fp, hash := range manifest.Files {
err = verifyHash(plugin.Id, filepath.Join(plugin.PluginDir, fp), hash)
for p, hash := range manifest.Files {
err = verifyHash(plugin.ID, filepath.Join(plugin.PluginDir, p), hash)
if err != nil {
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureModified,
return plugins.Signature{
Status: plugins.SignatureModified,
}, nil
}
manifestFiles[fp] = struct{}{}
manifestFiles[p] = struct{}{}
}
if manifest.isV2() {
pluginFiles, err := pluginFilesRequiringVerification(plugin)
if err != nil {
log.Warn("Could not collect plugin file information in directory", "pluginID", plugin.Id, "dir", plugin.PluginDir)
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureInvalid,
log.Warn("Could not collect plugin file information in directory", "pluginID", plugin.ID, "dir", plugin.PluginDir)
return plugins.Signature{
Status: plugins.SignatureInvalid,
}, err
}
@@ -202,20 +198,18 @@ func getPluginSignatureState(log log.Logger, plugin *plugins.PluginBase) (plugin
}
if len(unsignedFiles) > 0 {
log.Warn("The following files were not included in the signature", "plugin", plugin.Id, "files", unsignedFiles)
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureModified,
log.Warn("The following files were not included in the signature", "plugin", plugin.ID, "files", unsignedFiles)
return plugins.Signature{
Status: plugins.SignatureModified,
}, nil
}
}
// Everything OK
log.Debug("Plugin signature valid", "id", plugin.Id)
return plugins.PluginSignatureState{
Status: plugins.PluginSignatureValid,
log.Debug("Plugin signature valid", "id", plugin.ID)
return plugins.Signature{
Status: plugins.SignatureValid,
Type: manifest.SignatureType,
SigningOrg: manifest.SignedByOrgName,
Files: manifestFiles,
}, nil
}
@@ -247,9 +241,9 @@ func verifyHash(pluginID string, path string, hash string) error {
return nil
}
// gets plugin filenames that require verification for plugin signing
// 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.PluginBase) ([]string, error) {
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 {
@@ -1,4 +1,4 @@
package manager
package signature
import (
"sort"
@@ -105,7 +105,7 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
assert.Equal(t, int64(1605807018050), manifest.Time)
assert.Equal(t, "7e4d0c6a708866e7", manifest.KeyID)
assert.Equal(t, "2.0.0", manifest.ManifestVersion)
assert.Equal(t, plugins.PrivateType, manifest.SignatureType)
assert.Equal(t, plugins.PrivateSignature, manifest.SignatureType)
assert.Equal(t, "willbrowne", manifest.SignedByOrg)
assert.Equal(t, "Will Browne", manifest.SignedByOrgName)
assert.Equal(t, []string{"http://localhost:3000/"}, manifest.RootURLs)
@@ -0,0 +1,81 @@
package signature
import (
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
)
var logger = log.New("plugin.signature.validator")
type Validator struct {
cfg *setting.Cfg
authorizer plugins.PluginLoaderAuthorizer
}
func NewValidator(cfg *setting.Cfg, authorizer plugins.PluginLoaderAuthorizer) Validator {
return Validator{
cfg: cfg,
authorizer: authorizer,
}
}
func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
if plugin.Signature == plugins.SignatureValid {
logger.Debug("Plugin has valid signature", "id", plugin.ID)
return nil
}
// If a plugin is nested within another, create links to each other to inherit signature details
if plugin.Parent != nil {
if plugin.IsCorePlugin() || plugin.Signature == plugins.SignatureInternal {
logger.Debug("Not setting descendant plugin's signature to that of root since it's core or internal",
"plugin", plugin.ID, "signature", plugin.Signature, "isCore", plugin.IsCorePlugin)
} else {
logger.Debug("Setting descendant plugin's signature to that of root", "plugin", plugin.ID,
"root", plugin.Parent.ID, "signature", plugin.Signature, "rootSignature", plugin.Parent.Signature)
plugin.Signature = plugin.Parent.Signature
plugin.SignatureType = plugin.Parent.SignatureType
plugin.SignatureOrg = plugin.Parent.SignatureOrg
if plugin.Signature == plugins.SignatureValid {
logger.Debug("Plugin has valid signature (inherited from root)", "id", plugin.ID)
return nil
}
}
}
if plugin.IsCorePlugin() || plugin.IsBundledPlugin() {
return nil
}
switch plugin.Signature {
case plugins.SignatureUnsigned:
if authorized := s.authorizer.CanLoadPlugin(plugin); !authorized {
logger.Debug("Plugin is unsigned", "pluginID", plugin.ID)
return &plugins.SignatureError{
PluginID: plugin.ID,
SignatureStatus: plugins.SignatureUnsigned,
}
}
logger.Warn("Permitting unsigned plugin. This is not recommended", "pluginID", plugin.ID, "pluginDir", plugin.PluginDir)
return nil
case plugins.SignatureInvalid:
logger.Debug("Plugin has an invalid signature", "pluginID", plugin.ID)
return &plugins.SignatureError{
PluginID: plugin.ID,
SignatureStatus: plugins.SignatureInvalid,
}
case plugins.SignatureModified:
logger.Debug("Plugin has a modified signature", "pluginID", plugin.ID)
return &plugins.SignatureError{
PluginID: plugin.ID,
SignatureStatus: plugins.SignatureModified,
}
default:
logger.Debug("Plugin has an unrecognized plugin signature state", "pluginID", plugin.ID, "signature",
plugin.Signature)
return &plugins.SignatureError{
PluginID: plugin.ID,
}
}
}
@@ -0,0 +1,4 @@
{
"id": "test-app",
"type": "application"
}
@@ -0,0 +1,28 @@
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
{
"manifestVersion": "2.0.0",
"signatureType": "grafana",
"signedByOrg": "grafana",
"signedByOrgName": "Grafana Labs",
"plugin": "test-ds",
"version": "1.0.0",
"time": 1629461930434,
"keyId": "7e4d0c6a708866e7",
"files": {
"plugin.json": "64e98031f30cfada473e0ad4b989ac10cd0c86844aab8c0d3fc36d8a9537a0b8",
"nested/plugin.json": "e64abd35cd211e0e4682974ad5cdd1be7a0b7cd24951d302a16d9e2cb6cefea4"
}
}
-----BEGIN PGP SIGNATURE-----
Version: OpenPGP.js v4.10.1
Comment: https://openpgpjs.org
wqIEARMKAAYFAmEfnaoACgkQfk0ManCIZufwYgIJAZULZ72BKYehVw362aOJ
IkUhCaIceQT6rSmWw60Ksxs8xkeCebMPfuxm6xqpvoquVmD2zIirCFUXE41M
SQBys7/aAgkBaaVZvVPLUMYHIGNQXQ0wJ0j6JGn5Mn25GH4lH4vttaCFpQmx
zwV8J/s7Ho612fU1ijH/nFM97I4nfxonQUEyEbA=
=7sr3
-----END PGP SIGNATURE-----
@@ -0,0 +1,14 @@
{
"type": "panel",
"name": "Child",
"id": "test-panel",
"info": {
"description": "Child plugin",
"author": {
"name": "Grafana Labs",
"url": "http://grafana.com"
},
"version": "1.0.1",
"updated": "2020-10-30"
}
}
@@ -0,0 +1,15 @@
{
"type": "datasource",
"name": "Parent",
"id": "test-ds",
"backend": true,
"info": {
"description": "Parent plugin",
"author": {
"name": "Grafana Labs",
"url": "http://grafana.com"
},
"version": "1.0.0",
"updated": "2020-10-20"
}
}
+24 -67
View File
@@ -8,7 +8,6 @@ import (
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/hashicorp/go-version"
)
@@ -16,38 +15,20 @@ var (
httpClient = http.Client{Timeout: 10 * time.Second}
)
type grafanaNetPlugin struct {
type gcomPlugin struct {
Slug string `json:"slug"`
Version string `json:"version"`
}
type gitHubLatest struct {
Stable string `json:"stable"`
Testing string `json:"testing"`
}
func (pm *PluginManager) getAllExternalPluginSlugs() string {
var result []string
for _, plug := range pm.plugins {
if plug.IsCorePlugin {
continue
}
result = append(result, plug.Id)
}
return strings.Join(result, ",")
}
func (pm *PluginManager) checkForUpdates() {
if !pm.Cfg.CheckForUpdates {
func (m *PluginManager) checkForUpdates() {
if !m.cfg.CheckForUpdates {
return
}
pm.log.Debug("Checking for updates")
m.log.Debug("Checking for updates")
pluginSlugs := pm.getAllExternalPluginSlugs()
resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion)
pluginSlugs := m.externalPluginIDsAsCSV()
resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + m.cfg.BuildVersion)
if err != nil {
log.Debug("Failed to get plugins repo from grafana.com", "error", err.Error())
return
@@ -64,64 +45,40 @@ func (pm *PluginManager) checkForUpdates() {
return
}
gNetPlugins := []grafanaNetPlugin{}
err = json.Unmarshal(body, &gNetPlugins)
var gcomPlugins []gcomPlugin
err = json.Unmarshal(body, &gcomPlugins)
if err != nil {
log.Debug("Failed to unmarshal plugin repo, reading response from grafana.com", "error", err.Error())
return
}
for _, plug := range pm.Plugins() {
for _, gplug := range gNetPlugins {
if gplug.Slug == plug.Id {
plug.GrafanaNetVersion = gplug.Version
for _, localP := range m.Plugins() {
for _, gcomP := range gcomPlugins {
if gcomP.Slug == localP.ID {
localP.GrafanaComVersion = gcomP.Version
plugVersion, err1 := version.NewVersion(plug.Info.Version)
gplugVersion, err2 := version.NewVersion(gplug.Version)
plugVersion, err1 := version.NewVersion(localP.Info.Version)
gplugVersion, err2 := version.NewVersion(gcomP.Version)
if err1 != nil || err2 != nil {
plug.GrafanaNetHasUpdate = plug.Info.Version != plug.GrafanaNetVersion
localP.GrafanaComHasUpdate = localP.Info.Version != localP.GrafanaComVersion
} else {
plug.GrafanaNetHasUpdate = plugVersion.LessThan(gplugVersion)
localP.GrafanaComHasUpdate = plugVersion.LessThan(gplugVersion)
}
}
}
}
}
resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/main/latest.json")
if err != nil {
log.Debug("Failed to get latest.json repo from github.com", "error", err.Error())
return
}
defer func() {
if err := resp2.Body.Close(); err != nil {
pm.log.Warn("Failed to close response body", "err", err)
func (m *PluginManager) externalPluginIDsAsCSV() string {
var result []string
for _, p := range m.plugins {
if p.IsCorePlugin() {
continue
}
}()
body, err = ioutil.ReadAll(resp2.Body)
if err != nil {
log.Debug("Update check failed, reading response from github.com", "error", err.Error())
return
}
var latest gitHubLatest
err = json.Unmarshal(body, &latest)
if err != nil {
log.Debug("Failed to unmarshal github.com latest, reading response from github.com", "error", err.Error())
return
result = append(result, p.ID)
}
if strings.Contains(setting.BuildVersion, "-") {
pm.grafanaLatestVersion = latest.Testing
pm.grafanaHasUpdate = !strings.HasPrefix(setting.BuildVersion, latest.Testing)
} else {
pm.grafanaLatestVersion = latest.Stable
pm.grafanaHasUpdate = latest.Stable != setting.BuildVersion
}
currVersion, err1 := version.NewVersion(setting.BuildVersion)
latestVersion, err2 := version.NewVersion(pm.grafanaLatestVersion)
if err1 == nil && err2 == nil {
pm.grafanaHasUpdate = currVersion.LessThan(latestVersion)
}
return strings.Join(result, ",")
}
+119 -89
View File
@@ -1,17 +1,14 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
)
const (
PluginTypeApp = "app"
PluginTypeDashboard = "dashboard"
TypeDashboard = "dashboard"
)
var (
@@ -21,89 +18,70 @@ var (
ErrPluginNotInstalled = errors.New("plugin is not installed")
)
type PluginNotFoundError struct {
type NotFoundError struct {
PluginID string
}
func (e PluginNotFoundError) Error() string {
func (e NotFoundError) Error() string {
return fmt.Sprintf("plugin with ID '%s' not found", e.PluginID)
}
type DuplicatePluginError struct {
type DuplicateError struct {
PluginID string
ExistingPluginDir string
}
func (e DuplicatePluginError) Error() string {
func (e DuplicateError) Error() string {
return fmt.Sprintf("plugin with ID '%s' already exists in '%s'", e.PluginID, e.ExistingPluginDir)
}
func (e DuplicatePluginError) Is(err error) bool {
func (e DuplicateError) Is(err error) bool {
// nolint:errorlint
_, ok := err.(DuplicatePluginError)
_, ok := err.(DuplicateError)
return ok
}
// PluginLoader can load a plugin.
type PluginLoader interface {
// Load loads a plugin and returns it.
Load(decoder *json.Decoder, base *PluginBase, backendPluginManager backendplugin.Manager) (interface{}, error)
type SignatureError struct {
PluginID string `json:"pluginId"`
SignatureStatus SignatureStatus `json:"status"`
}
// PluginBase is the base plugin type.
type PluginBase struct {
Type string `json:"type"`
Name string `json:"name"`
Id string `json:"id"`
Info PluginInfo `json:"info"`
Dependencies PluginDependencies `json:"dependencies"`
Includes []*PluginInclude `json:"includes"`
Module string `json:"module"`
BaseUrl string `json:"baseUrl"`
Category string `json:"category"`
HideFromList bool `json:"hideFromList,omitempty"`
Preload bool `json:"preload"`
State PluginState `json:"state,omitempty"`
Signature PluginSignatureStatus `json:"signature"`
Backend bool `json:"backend"`
IncludedInAppId string `json:"-"`
PluginDir string `json:"-"`
DefaultNavUrl string `json:"-"`
IsCorePlugin bool `json:"-"`
SignatureType PluginSignatureType `json:"-"`
SignatureOrg string `json:"-"`
SignedFiles PluginFiles `json:"-"`
GrafanaNetVersion string `json:"-"`
GrafanaNetHasUpdate bool `json:"-"`
Root *PluginBase
}
func (p *PluginBase) IncludedInSignature(file string) bool {
// permit Core plugin files
if p.IsCorePlugin {
return true
func (e SignatureError) Error() string {
switch e.SignatureStatus {
case SignatureInvalid:
return fmt.Sprintf("plugin '%s' has an invalid signature", e.PluginID)
case SignatureModified:
return fmt.Sprintf("plugin '%s' has an modified signature", e.PluginID)
case SignatureUnsigned:
return fmt.Sprintf("plugin '%s' has no signature", e.PluginID)
case SignatureInternal, SignatureValid:
return ""
}
// permit when no signed files (no MANIFEST)
if p.SignedFiles == nil {
return true
}
if _, exists := p.SignedFiles[file]; !exists {
return false
}
return true
return fmt.Sprintf("plugin '%s' has an unknown signature state", e.PluginID)
}
type PluginDependencies struct {
GrafanaVersion string `json:"grafanaVersion"`
Plugins []PluginDependencyItem `json:"plugins"`
func (e SignatureError) AsErrorCode() ErrorCode {
switch e.SignatureStatus {
case SignatureInvalid:
return signatureInvalid
case SignatureModified:
return signatureModified
case SignatureUnsigned:
return signatureMissing
case SignatureInternal, SignatureValid:
return ""
}
return ""
}
type PluginInclude struct {
type Dependencies struct {
GrafanaVersion string `json:"grafanaVersion"`
Plugins []Dependency `json:"plugins"`
}
type Includes struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
@@ -115,10 +93,10 @@ type PluginInclude struct {
Icon string `json:"icon"`
UID string `json:"uid"`
Id string `json:"-"`
ID string `json:"-"`
}
func (e PluginInclude) GetSlugOrUIDLink() string {
func (e Includes) GetSlugOrUIDLink() string {
if len(e.UID) > 0 {
return "/d/" + e.UID
} else {
@@ -126,57 +104,109 @@ func (e PluginInclude) GetSlugOrUIDLink() string {
}
}
type PluginDependencyItem struct {
type Dependency struct {
ID string `json:"id"`
Type string `json:"type"`
Id string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
}
type PluginBuildInfo struct {
type BuildInfo struct {
Time int64 `json:"time,omitempty"`
Repo string `json:"repo,omitempty"`
Branch string `json:"branch,omitempty"`
Hash string `json:"hash,omitempty"`
}
type PluginInfo struct {
Author PluginInfoLink `json:"author"`
Description string `json:"description"`
Links []PluginInfoLink `json:"links"`
Logos PluginLogos `json:"logos"`
Build PluginBuildInfo `json:"build"`
Screenshots []PluginScreenshots `json:"screenshots"`
Version string `json:"version"`
Updated string `json:"updated"`
type Info struct {
Author InfoLink `json:"author"`
Description string `json:"description"`
Links []InfoLink `json:"links"`
Logos Logos `json:"logos"`
Build BuildInfo `json:"build"`
Screenshots []Screenshots `json:"screenshots"`
Version string `json:"version"`
Updated string `json:"updated"`
}
type PluginInfoLink struct {
type InfoLink struct {
Name string `json:"name"`
Url string `json:"url"`
URL string `json:"url"`
}
type PluginLogos struct {
type Logos struct {
Small string `json:"small"`
Large string `json:"large"`
}
type PluginScreenshots struct {
Path string `json:"path"`
type Screenshots struct {
Name string `json:"name"`
Path string `json:"path"`
}
type PluginStaticRoute struct {
type StaticRoute struct {
PluginID string
Directory string
PluginId string
}
type EnabledPlugins struct {
Panels []*PanelPlugin
DataSources map[string]*DataSourcePlugin
Apps []*AppPlugin
type SignatureStatus string
func (ss SignatureStatus) IsValid() bool {
return ss == SignatureValid
}
type UpdateInfo struct {
PluginZipURL string
func (ss SignatureStatus) IsInternal() bool {
return ss == SignatureInternal
}
const (
SignatureInternal SignatureStatus = "internal" // core plugin, no signature
SignatureValid SignatureStatus = "valid" // signed and accurate MANIFEST
SignatureInvalid SignatureStatus = "invalid" // invalid signature
SignatureModified SignatureStatus = "modified" // valid signature, but content mismatch
SignatureUnsigned SignatureStatus = "unsigned" // no MANIFEST file
)
type ReleaseState string
const (
AlphaRelease ReleaseState = "alpha"
)
type SignatureType string
const (
GrafanaSignature SignatureType = "grafana"
PrivateSignature SignatureType = "private"
)
type PluginFiles map[string]struct{}
type Signature struct {
Status SignatureStatus
Type SignatureType
SigningOrg string
Files PluginFiles
}
type PluginMetaDTO struct {
JSONData
Signature SignatureStatus `json:"signature"`
Module string `json:"module"`
BaseURL string `json:"baseUrl"`
}
const (
signatureMissing ErrorCode = "signatureMissing"
signatureModified ErrorCode = "signatureModified"
signatureInvalid ErrorCode = "signatureInvalid"
)
type ErrorCode string
type Error struct {
ErrorCode `json:"errorCode"`
PluginID string `json:"pluginId,omitempty"`
}
-25
View File
@@ -1,25 +0,0 @@
package plugins
import (
"encoding/json"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
)
type PanelPlugin struct {
FrontendPluginBase
SkipDataQuery bool `json:"skipDataQuery"`
}
func (p *PanelPlugin) Load(decoder *json.Decoder, base *PluginBase, backendPluginManager backendplugin.Manager) (
interface{}, error) {
if err := decoder.Decode(p); err != nil {
return nil, err
}
if p.Id == "grafana-piechart-panel" {
p.Name = "Pie Chart (old)"
}
return p, nil
}
+6 -5
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
@@ -20,13 +21,13 @@ import (
"github.com/grafana/grafana/pkg/util/errutil"
)
func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginManager plugins.Manager,
func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginStore plugins.Store,
dataSourceCache datasources.CacheService, encryptionService encryption.Service,
pluginSettingsService *pluginsettings.Service) *Provider {
return &Provider{
Bus: bus,
CacheService: cacheService,
PluginManager: pluginManager,
pluginStore: pluginStore,
DataSourceCache: dataSourceCache,
EncryptionService: encryptionService,
PluginSettingsService: pluginSettingsService,
@@ -37,7 +38,7 @@ func ProvideService(bus bus.Bus, cacheService *localcache.CacheService, pluginMa
type Provider struct {
Bus bus.Bus
CacheService *localcache.CacheService
PluginManager plugins.Manager
pluginStore plugins.Store
DataSourceCache datasources.CacheService
EncryptionService encryption.Service
PluginSettingsService *pluginsettings.Service
@@ -49,7 +50,7 @@ type Provider struct {
// returned context.
func (p *Provider) Get(pluginID string, datasourceUID string, user *models.SignedInUser, skipCache bool) (backend.PluginContext, bool, error) {
pc := backend.PluginContext{}
plugin := p.PluginManager.GetPlugin(pluginID)
plugin := p.pluginStore.Plugin(pluginID)
if plugin == nil {
return pc, false, nil
}
@@ -76,7 +77,7 @@ func (p *Provider) Get(pluginID string, datasourceUID string, user *models.Signe
pCtx := backend.PluginContext{
OrgID: user.OrgId,
PluginID: plugin.Id,
PluginID: plugin.ID,
User: adapters.BackendUserFromSignedInUser(user),
AppInstanceSettings: &backend.AppInstanceSettings{
JSONData: jsonData,
+22 -22
View File
@@ -6,15 +6,15 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/tsdb"
)
func ProvideService(dataService *tsdb.Service, pluginManager plugins.Manager, sqlStore *sqlstore.SQLStore) *Service {
func ProvideService(pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager,
sqlStore *sqlstore.SQLStore) *Service {
s := &Service{
DataService: dataService,
PluginManager: pluginManager,
SQLStore: sqlStore,
logger: log.New("plugindashboards"),
sqlStore: sqlStore,
pluginStore: pluginStore,
pluginDashboardManager: pluginDashboardManager,
logger: log.New("plugindashboards"),
}
bus.AddEventListener(s.handlePluginStateChanged)
s.updateAppDashboards()
@@ -22,9 +22,9 @@ func ProvideService(dataService *tsdb.Service, pluginManager plugins.Manager, sq
}
type Service struct {
DataService *tsdb.Service
PluginManager plugins.Manager
SQLStore *sqlstore.SQLStore
sqlStore *sqlstore.SQLStore
pluginStore plugins.Store
pluginDashboardManager plugins.PluginDashboardManager
logger log.Logger
}
@@ -32,7 +32,7 @@ type Service struct {
func (s *Service) updateAppDashboards() {
s.logger.Debug("Looking for app dashboard updates")
pluginSettings, err := s.SQLStore.GetPluginSettings(0)
pluginSettings, err := s.sqlStore.GetPluginSettings(0)
if err != nil {
s.logger.Error("Failed to get all plugin settings", "error", err)
return
@@ -44,7 +44,7 @@ func (s *Service) updateAppDashboards() {
continue
}
if pluginDef := s.PluginManager.GetPlugin(pluginSetting.PluginId); pluginDef != nil {
if pluginDef := s.pluginStore.Plugin(pluginSetting.PluginId); pluginDef != nil {
if pluginDef.Info.Version != pluginSetting.PluginVersion {
s.syncPluginDashboards(pluginDef, pluginSetting.OrgId)
}
@@ -52,11 +52,11 @@ func (s *Service) updateAppDashboards() {
}
}
func (s *Service) syncPluginDashboards(pluginDef *plugins.PluginBase, orgID int64) {
s.logger.Info("Syncing plugin dashboards to DB", "pluginId", pluginDef.Id)
func (s *Service) syncPluginDashboards(pluginDef *plugins.Plugin, orgID int64) {
s.logger.Info("Syncing plugin dashboards to DB", "pluginId", pluginDef.ID)
// Get plugin dashboards
dashboards, err := s.PluginManager.GetPluginDashboards(orgID, pluginDef.Id)
dashboards, err := s.pluginDashboardManager.GetPluginDashboards(orgID, pluginDef.ID)
if err != nil {
s.logger.Error("Failed to load app dashboards", "error", err)
return
@@ -66,11 +66,11 @@ func (s *Service) syncPluginDashboards(pluginDef *plugins.PluginBase, orgID int6
for _, dash := range dashboards {
// remove removed ones
if dash.Removed {
s.logger.Info("Deleting plugin dashboard", "pluginId", pluginDef.Id, "dashboard", dash.Slug)
s.logger.Info("Deleting plugin dashboard", "pluginId", pluginDef.ID, "dashboard", dash.Slug)
deleteCmd := models.DeleteDashboardCommand{OrgId: orgID, Id: dash.DashboardId}
if err := bus.Dispatch(&deleteCmd); err != nil {
s.logger.Error("Failed to auto update app dashboard", "pluginId", pluginDef.Id, "error", err)
s.logger.Error("Failed to auto update app dashboard", "pluginId", pluginDef.ID, "error", err)
return
}
@@ -80,14 +80,14 @@ func (s *Service) syncPluginDashboards(pluginDef *plugins.PluginBase, orgID int6
// update updated ones
if dash.ImportedRevision != dash.Revision {
if err := s.autoUpdateAppDashboard(dash, orgID); err != nil {
s.logger.Error("Failed to auto update app dashboard", "pluginId", pluginDef.Id, "error", err)
s.logger.Error("Failed to auto update app dashboard", "pluginId", pluginDef.ID, "error", err)
return
}
}
}
// update version in plugin_setting table to mark that we have processed the update
query := models.GetPluginSettingByIdQuery{PluginId: pluginDef.Id, OrgId: orgID}
query := models.GetPluginSettingByIdQuery{PluginId: pluginDef.ID, OrgId: orgID}
if err := bus.Dispatch(&query); err != nil {
s.logger.Error("Failed to read plugin setting by ID", "error", err)
return
@@ -109,7 +109,7 @@ func (s *Service) handlePluginStateChanged(event *models.PluginStateChangedEvent
s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
if event.Enabled {
s.syncPluginDashboards(s.PluginManager.GetPlugin(event.PluginId), event.OrgId)
s.syncPluginDashboards(s.pluginStore.Plugin(event.PluginId), event.OrgId)
} else {
query := models.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId}
if err := bus.Dispatch(&query); err != nil {
@@ -129,14 +129,14 @@ func (s *Service) handlePluginStateChanged(event *models.PluginStateChangedEvent
}
func (s *Service) autoUpdateAppDashboard(pluginDashInfo *plugins.PluginDashboardInfoDTO, orgID int64) error {
dash, err := s.PluginManager.LoadPluginDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path)
dash, err := s.pluginDashboardManager.LoadPluginDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path)
if err != nil {
return err
}
s.logger.Info("Auto updating App dashboard", "dashboard", dash.Title, "newRev",
pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision)
user := &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN}
_, _, err = s.PluginManager.ImportDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path, orgID, 0, dash.Data, true,
nil, user, s.DataService)
_, _, err = s.pluginDashboardManager.ImportDashboard(pluginDashInfo.PluginId, pluginDashInfo.Path, orgID, 0, dash.Data, true,
nil, user)
return err
}
+347 -1
View File
@@ -1,2 +1,348 @@
// Package plugins contains plugin related logic.
package plugins
import (
"context"
"encoding/json"
"fmt"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
)
type Plugin struct {
JSONData
PluginDir string
Class Class
// App fields
IncludedInAppID string
DefaultNavURL string
Pinned bool
// Signature fields
Signature SignatureStatus
SignatureType SignatureType
SignatureOrg string
Parent *Plugin
Children []*Plugin
SignedFiles PluginFiles
SignatureError *SignatureError
// GCOM update checker fields
GrafanaComVersion string
GrafanaComHasUpdate bool
// SystemJS fields
Module string
BaseURL string
Renderer pluginextensionv2.RendererPlugin
client backendplugin.Plugin
log log.Logger
}
// JSONData represents the plugin's plugin.json
type JSONData struct {
// Common settings
ID string `json:"id"`
Type Type `json:"type"`
Name string `json:"name"`
Info Info `json:"info"`
Dependencies Dependencies `json:"dependencies"`
Includes []*Includes `json:"includes"`
State ReleaseState `json:"state,omitempty"`
Category string `json:"category"`
HideFromList bool `json:"hideFromList,omitempty"`
Preload bool `json:"preload"`
Backend bool `json:"backend"`
Routes []*Route `json:"routes"`
// Panel settings
SkipDataQuery bool `json:"skipDataQuery"`
// App settings
AutoEnabled bool `json:"autoEnabled"`
// Datasource settings
Annotations bool `json:"annotations"`
Metrics bool `json:"metrics"`
Alerting bool `json:"alerting"`
Explore bool `json:"explore"`
Table bool `json:"tables"`
Logs bool `json:"logs"`
Tracing bool `json:"tracing"`
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
BuiltIn bool `json:"builtIn,omitempty"`
Mixed bool `json:"mixed,omitempty"`
Streaming bool `json:"streaming"`
SDK bool `json:"sdk,omitempty"`
// Backend (Datasource + Renderer)
Executable string `json:"executable,omitempty"`
}
// Route describes a plugin route that is defined in
// the plugin.json file for a plugin.
type Route struct {
Path string `json:"path"`
Method string `json:"method"`
ReqRole models.RoleType `json:"reqRole"`
URL string `json:"url"`
URLParams []URLParam `json:"urlParams"`
Headers []Header `json:"headers"`
AuthType string `json:"authType"`
TokenAuth *JWTTokenAuth `json:"tokenAuth"`
JwtTokenAuth *JWTTokenAuth `json:"jwtTokenAuth"`
Body json.RawMessage `json:"body"`
}
// Header describes an HTTP header that is forwarded with
// the proxied request for a plugin route
type Header struct {
Name string `json:"name"`
Content string `json:"content"`
}
// URLParam describes query string parameters for
// a url in a plugin route
type URLParam struct {
Name string `json:"name"`
Content string `json:"content"`
}
// JWTTokenAuth struct is both for normal Token Auth and JWT Token Auth with
// an uploaded JWT file.
type JWTTokenAuth struct {
Url string `json:"url"`
Scopes []string `json:"scopes"`
Params map[string]string `json:"params"`
}
func (p *Plugin) PluginID() string {
return p.ID
}
func (p *Plugin) Logger() log.Logger {
return p.log
}
func (p *Plugin) SetLogger(l log.Logger) {
p.log = l
}
func (p *Plugin) Start(ctx context.Context) error {
if p.client == nil {
return fmt.Errorf("could not start plugin %s as no plugin client exists", p.ID)
}
return p.client.Start(ctx)
}
func (p *Plugin) Stop(ctx context.Context) error {
if p.client == nil {
return nil
}
return p.client.Stop(ctx)
}
func (p *Plugin) IsManaged() bool {
if p.client != nil {
return p.client.IsManaged()
}
return false
}
func (p *Plugin) Decommission() error {
if p.client != nil {
return p.client.Decommission()
}
return nil
}
func (p *Plugin) IsDecommissioned() bool {
if p.client != nil {
return p.client.IsDecommissioned()
}
return false
}
func (p *Plugin) Exited() bool {
if p.client != nil {
return p.client.Exited()
}
return false
}
func (p *Plugin) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
pluginClient, ok := p.Client()
if !ok {
return nil, backendplugin.ErrPluginUnavailable
}
return pluginClient.QueryData(ctx, req)
}
func (p *Plugin) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
pluginClient, ok := p.Client()
if !ok {
return backendplugin.ErrPluginUnavailable
}
return pluginClient.CallResource(ctx, req, sender)
}
func (p *Plugin) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
pluginClient, ok := p.Client()
if !ok {
return nil, backendplugin.ErrPluginUnavailable
}
return pluginClient.CheckHealth(ctx, req)
}
func (p *Plugin) CollectMetrics(ctx context.Context) (*backend.CollectMetricsResult, error) {
pluginClient, ok := p.Client()
if !ok {
return nil, backendplugin.ErrPluginUnavailable
}
return pluginClient.CollectMetrics(ctx)
}
func (p *Plugin) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
pluginClient, ok := p.Client()
if !ok {
return nil, backendplugin.ErrPluginUnavailable
}
return pluginClient.SubscribeStream(ctx, req)
}
func (p *Plugin) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
pluginClient, ok := p.Client()
if !ok {
return nil, backendplugin.ErrPluginUnavailable
}
return pluginClient.PublishStream(ctx, req)
}
func (p *Plugin) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
pluginClient, ok := p.Client()
if !ok {
return backendplugin.ErrPluginUnavailable
}
return pluginClient.RunStream(ctx, req, sender)
}
func (p *Plugin) RegisterClient(c backendplugin.Plugin) {
p.client = c
}
func (p *Plugin) Client() (PluginClient, bool) {
if p.client != nil {
return p.client, true
}
return nil, false
}
type PluginClient interface {
backend.QueryDataHandler
backend.CollectMetricsHandler
backend.CheckHealthHandler
backend.CallResourceHandler
backend.StreamHandler
}
func (p *Plugin) StaticRoute() *StaticRoute {
if p.IsCorePlugin() {
return nil
}
return &StaticRoute{Directory: p.PluginDir, PluginID: p.ID}
}
func (p *Plugin) IsRenderer() bool {
return p.Type == "renderer"
}
func (p *Plugin) IsDataSource() bool {
return p.Type == "datasource"
}
func (p *Plugin) IsPanel() bool {
return p.Type == "panel"
}
func (p *Plugin) IsApp() bool {
return p.Type == "app"
}
func (p *Plugin) IsCorePlugin() bool {
return p.Class == Core
}
func (p *Plugin) IsBundledPlugin() bool {
return p.Class == Bundled
}
func (p *Plugin) IsExternalPlugin() bool {
return p.Class == External
}
func (p *Plugin) SupportsStreaming() bool {
pluginClient, ok := p.Client()
if !ok {
return false
}
_, ok = pluginClient.(backend.StreamHandler)
return ok
}
func (p *Plugin) IncludedInSignature(file string) bool {
// permit Core plugin files
if p.IsCorePlugin() {
return true
}
// permit when no signed files (no MANIFEST)
if p.SignedFiles == nil {
return true
}
if _, exists := p.SignedFiles[file]; !exists {
return false
}
return true
}
type Class string
const (
Core Class = "core"
Bundled Class = "bundled"
External Class = "external"
)
var PluginTypes = []Type{
DataSource,
Panel,
App,
Renderer,
}
type Type string
const (
DataSource Type = "datasource"
Panel Type = "panel"
App Type = "app"
Renderer Type = "renderer"
)
func (pt Type) IsValid() bool {
switch pt {
case DataSource, Panel, App, Renderer:
return true
}
return false
}
-52
View File
@@ -1,52 +0,0 @@
package plugins
import (
"context"
"encoding/json"
"path/filepath"
"github.com/grafana/grafana/pkg/infra/log"
"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/util/errutil"
)
type RendererPlugin struct {
FrontendPluginBase
Executable string `json:"executable,omitempty"`
GrpcPluginV2 pluginextensionv2.RendererPlugin
backendPluginManager backendplugin.Manager
}
func (r *RendererPlugin) Load(decoder *json.Decoder, base *PluginBase,
backendPluginManager backendplugin.Manager) (interface{}, error) {
if err := decoder.Decode(r); err != nil {
return nil, err
}
r.backendPluginManager = backendPluginManager
cmd := ComposePluginStartCommand("plugin_start")
fullpath := filepath.Join(base.PluginDir, cmd)
factory := grpcplugin.NewRendererPlugin(r.Id, fullpath, r.onPluginStart)
if err := backendPluginManager.Register(r.Id, factory); err != nil {
return nil, errutil.Wrapf(err, "failed to register backend plugin")
}
return r, nil
}
func (r *RendererPlugin) Start(ctx context.Context) error {
if err := r.backendPluginManager.StartPlugin(ctx, r.Id); err != nil {
return errutil.Wrapf(err, "Failed to start renderer plugin")
}
return nil
}
func (r *RendererPlugin) onPluginStart(pluginID string, renderer pluginextensionv2.RendererPlugin, logger log.Logger) error {
r.GrpcPluginV2 = renderer
return nil
}
-41
View File
@@ -1,41 +0,0 @@
package plugins
type PluginSignatureStatus string
func (pss PluginSignatureStatus) IsValid() bool {
return pss == PluginSignatureValid
}
func (pss PluginSignatureStatus) IsInternal() bool {
return pss == PluginSignatureInternal
}
const (
PluginSignatureInternal PluginSignatureStatus = "internal" // core plugin, no signature
PluginSignatureValid PluginSignatureStatus = "valid" // signed and accurate MANIFEST
PluginSignatureInvalid PluginSignatureStatus = "invalid" // invalid signature
PluginSignatureModified PluginSignatureStatus = "modified" // valid signature, but content mismatch
PluginSignatureUnsigned PluginSignatureStatus = "unsigned" // no MANIFEST file
)
type PluginState string
const (
PluginStateAlpha PluginState = "alpha"
)
type PluginSignatureType string
const (
GrafanaType PluginSignatureType = "grafana"
PrivateType PluginSignatureType = "private"
)
type PluginFiles map[string]struct{}
type PluginSignatureState struct {
Status PluginSignatureStatus
Type PluginSignatureType
SigningOrg string
Files PluginFiles
}