Introduce TSDB service (#31520)
* Introduce TSDB service Signed-off-by: Arve Knudsen <arve.knudsen@gmail.com> Co-authored-by: Erik Sundell <erik.sundell87@gmail.com> Co-authored-by: Will Browne <will.browne@grafana.com> Co-authored-by: Torkel Ödegaard <torkel@grafana.org> Co-authored-by: Will Browne <wbrowne@users.noreply.github.com> Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
co-authored by
Erik Sundell
Will Browne
Torkel Ödegaard
Will Browne
Zoltán Bedi
parent
c899bf3592
commit
b79e61656a
@@ -0,0 +1,170 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
"github.com/grafana/grafana/pkg/tsdb/tsdbifaces"
|
||||
)
|
||||
|
||||
var varRegex = regexp.MustCompile(`(\$\{.+?\})`)
|
||||
|
||||
type ImportDashboardInput struct {
|
||||
Type string `json:"type"`
|
||||
PluginId string `json:"pluginId"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type DashboardInputMissingError struct {
|
||||
VariableName string
|
||||
}
|
||||
|
||||
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 []ImportDashboardInput, user *models.SignedInUser,
|
||||
requestHandler tsdbifaces.RequestHandler) (plugins.PluginDashboardInfoDTO, error) {
|
||||
var dashboard *models.Dashboard
|
||||
if pluginID != "" {
|
||||
var err error
|
||||
if dashboard, err = pm.LoadPluginDashboard(pluginID, path); err != nil {
|
||||
return plugins.PluginDashboardInfoDTO{}, err
|
||||
}
|
||||
} else {
|
||||
dashboard = models.NewDashboardFromJson(dashboardModel)
|
||||
}
|
||||
|
||||
evaluator := &DashTemplateEvaluator{
|
||||
template: dashboard.Data,
|
||||
inputs: inputs,
|
||||
}
|
||||
|
||||
generatedDash, err := evaluator.Eval()
|
||||
if err != nil {
|
||||
return plugins.PluginDashboardInfoDTO{}, 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(requestHandler).ImportDashboard(dto)
|
||||
if err != nil {
|
||||
return plugins.PluginDashboardInfoDTO{}, 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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type DashTemplateEvaluator struct {
|
||||
template *simplejson.Json
|
||||
inputs []ImportDashboardInput
|
||||
variables map[string]string
|
||||
result *simplejson.Json
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) findInput(varName string, varType string) *ImportDashboardInput {
|
||||
for _, input := range e.inputs {
|
||||
if varType == input.Type && (input.Name == varName || input.Name == "*") {
|
||||
return &input
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) Eval() (*simplejson.Json, error) {
|
||||
e.result = simplejson.New()
|
||||
e.variables = make(map[string]string)
|
||||
|
||||
// check that we have all inputs we need
|
||||
for _, inputDef := range e.template.Get("__inputs").MustArray() {
|
||||
inputDefJson := simplejson.NewFromAny(inputDef)
|
||||
inputName := inputDefJson.Get("name").MustString()
|
||||
inputType := inputDefJson.Get("type").MustString()
|
||||
input := e.findInput(inputName, inputType)
|
||||
|
||||
if input == nil {
|
||||
return nil, &DashboardInputMissingError{VariableName: inputName}
|
||||
}
|
||||
|
||||
e.variables["${"+inputName+"}"] = input.Value
|
||||
}
|
||||
|
||||
return simplejson.NewFromAny(e.evalObject(e.template)), nil
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) evalValue(source *simplejson.Json) interface{} {
|
||||
sourceValue := source.Interface()
|
||||
|
||||
switch v := sourceValue.(type) {
|
||||
case string:
|
||||
interpolated := varRegex.ReplaceAllStringFunc(v, func(match string) string {
|
||||
replacement, exists := e.variables[match]
|
||||
if exists {
|
||||
return replacement
|
||||
}
|
||||
return match
|
||||
})
|
||||
return interpolated
|
||||
case bool:
|
||||
return v
|
||||
case json.Number:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
return e.evalObject(source)
|
||||
case []interface{}:
|
||||
array := make([]interface{}, 0)
|
||||
for _, item := range v {
|
||||
array = append(array, e.evalValue(simplejson.NewFromAny(item)))
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) evalObject(source *simplejson.Json) interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
for key, value := range source.MustMap() {
|
||||
if key == "__inputs" {
|
||||
continue
|
||||
}
|
||||
result[key] = e.evalValue(simplejson.NewFromAny(value))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDashboardImport(t *testing.T) {
|
||||
pluginScenario(t, "When importing a plugin dashboard", func(t *testing.T, pm *PluginManager) {
|
||||
origNewDashboardService := dashboards.NewService
|
||||
t.Cleanup(func() {
|
||||
dashboards.NewService = origNewDashboardService
|
||||
})
|
||||
mock := &dashboards.FakeDashboardService{}
|
||||
dashboards.MockDashboardService(mock)
|
||||
|
||||
info, err := pm.ImportDashboard("test-app", "dashboards/connections.json", 1, 0, nil, false,
|
||||
[]ImportDashboardInput{
|
||||
{Name: "*", Type: "datasource", Value: "graphite"},
|
||||
}, &models.SignedInUser{UserId: 1, OrgRole: models.ROLE_ADMIN}, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info)
|
||||
|
||||
resultStr, err := mock.SavedDashboards[0].Dashboard.Data.EncodePretty()
|
||||
require.NoError(t, err)
|
||||
expectedBytes, err := ioutil.ReadFile("testdata/test-app/dashboards/connections_result.json")
|
||||
require.NoError(t, err)
|
||||
expectedJson, err := simplejson.NewJson(expectedBytes)
|
||||
require.NoError(t, err)
|
||||
expectedStr, err := expectedJson.EncodePretty()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, expectedStr, resultStr)
|
||||
|
||||
panel := mock.SavedDashboards[0].Dashboard.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0)
|
||||
require.Equal(t, "graphite", panel.Get("datasource").MustString())
|
||||
})
|
||||
|
||||
t.Run("When evaling dashboard template", func(t *testing.T) {
|
||||
template, err := simplejson.NewJson([]byte(`{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_NAME",
|
||||
"type": "datasource"
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"prop": "${DS_NAME}_${DS_NAME}"
|
||||
}
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
evaluator := &DashTemplateEvaluator{
|
||||
template: template,
|
||||
inputs: []ImportDashboardInput{
|
||||
{Name: "*", Type: "datasource", Value: "my-server"},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := evaluator.Eval()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "my-server_my-server", res.GetPath("test", "prop").MustString())
|
||||
|
||||
inputs := res.Get("__inputs")
|
||||
require.Nil(t, inputs.Interface())
|
||||
})
|
||||
}
|
||||
|
||||
func pluginScenario(t *testing.T, desc string, fn func(*testing.T, *PluginManager)) {
|
||||
t.Helper()
|
||||
|
||||
t.Run("Given a plugin", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
FeatureToggles: map[string]bool{},
|
||||
PluginSettings: setting.PluginSettings{
|
||||
"test-app": map[string]string{
|
||||
"path": "testdata/test-app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
fn(t, pm)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func (pm *PluginManager) GetPluginDashboards(orgId int64, pluginId string) ([]*plugins.PluginDashboardInfoDTO, error) {
|
||||
plugin, exists := Plugins[pluginId]
|
||||
if !exists {
|
||||
return nil, plugins.PluginNotFoundError{PluginID: pluginId}
|
||||
}
|
||||
|
||||
result := make([]*plugins.PluginDashboardInfoDTO, 0)
|
||||
|
||||
// load current dashboards
|
||||
query := models.GetDashboardsByPluginIdQuery{OrgId: orgId, PluginId: pluginId}
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingMatches := make(map[int64]bool)
|
||||
for _, include := range plugin.Includes {
|
||||
if include.Type != plugins.PluginTypeDashboard {
|
||||
continue
|
||||
}
|
||||
|
||||
dashboard, err := pm.LoadPluginDashboard(plugin.Id, include.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &plugins.PluginDashboardInfoDTO{}
|
||||
res.Path = include.Path
|
||||
res.PluginId = plugin.Id
|
||||
res.Title = dashboard.Title
|
||||
res.Revision = dashboard.Data.Get("revision").MustInt64(1)
|
||||
|
||||
// find existing dashboard
|
||||
for _, existingDash := range query.Result {
|
||||
if existingDash.Slug == dashboard.Slug {
|
||||
res.DashboardId = existingDash.Id
|
||||
res.Imported = true
|
||||
res.ImportedUri = "db/" + existingDash.Slug
|
||||
res.ImportedUrl = existingDash.GetUrl()
|
||||
res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
|
||||
existingMatches[existingDash.Id] = true
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, res)
|
||||
}
|
||||
|
||||
// find deleted dashboards
|
||||
for _, dash := range query.Result {
|
||||
if _, exists := existingMatches[dash.Id]; !exists {
|
||||
result = append(result, &plugins.PluginDashboardInfoDTO{
|
||||
Slug: dash.Slug,
|
||||
DashboardId: dash.Id,
|
||||
Removed: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (pm *PluginManager) LoadPluginDashboard(pluginId, path string) (*models.Dashboard, error) {
|
||||
plugin, exists := Plugins[pluginId]
|
||||
if !exists {
|
||||
return nil, plugins.PluginNotFoundError{PluginID: pluginId}
|
||||
}
|
||||
|
||||
dashboardFilePath := filepath.Join(plugin.PluginDir, path)
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based
|
||||
// on plugin folder structure on disk and not user input. `path` comes from the
|
||||
// `plugin.json` configuration file for the loaded plugin
|
||||
reader, err := os.Open(dashboardFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
plog.Warn("Failed to close file", "path", dashboardFilePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
data, err := simplejson.NewFromReader(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return models.NewDashboardFromJson(data), nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestPluginDashboards(t *testing.T) {
|
||||
Convey("When asking for plugin dashboard info", t, func() {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
FeatureToggles: map[string]bool{},
|
||||
PluginSettings: setting.PluginSettings{
|
||||
"test-app": map[string]string{
|
||||
"path": "testdata/test-app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := pm.Init()
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetDashboardQuery) error {
|
||||
if query.Slug == "nginx-connections" {
|
||||
dash := models.NewDashboard("Nginx Connections")
|
||||
dash.Data.Set("revision", "1.1")
|
||||
query.Result = dash
|
||||
return nil
|
||||
}
|
||||
|
||||
return models.ErrDashboardNotFound
|
||||
})
|
||||
|
||||
bus.AddHandler("test", func(query *models.GetDashboardsByPluginIdQuery) error {
|
||||
var data = simplejson.New()
|
||||
data.Set("title", "Nginx Connections")
|
||||
data.Set("revision", 22)
|
||||
|
||||
query.Result = []*models.Dashboard{
|
||||
{Slug: "nginx-connections", Data: data},
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
dashboards, err := pm.GetPluginDashboards(1, "test-app")
|
||||
So(err, ShouldBeNil)
|
||||
|
||||
Convey("should return 2 dashboards", func() {
|
||||
So(len(dashboards), ShouldEqual, 2)
|
||||
})
|
||||
|
||||
Convey("should include installed version info", func() {
|
||||
So(dashboards[0].Title, ShouldEqual, "Nginx Connections")
|
||||
So(dashboards[0].Revision, ShouldEqual, 25)
|
||||
So(dashboards[0].ImportedRevision, ShouldEqual, 22)
|
||||
So(dashboards[0].ImportedUri, ShouldEqual, "db/nginx-connections")
|
||||
|
||||
So(dashboards[1].Revision, ShouldEqual, 2)
|
||||
So(dashboards[1].ImportedRevision, ShouldEqual, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
)
|
||||
|
||||
const (
|
||||
signatureMissing plugins.ErrorCode = "signatureMissing"
|
||||
signatureModified plugins.ErrorCode = "signatureModified"
|
||||
signatureInvalid plugins.ErrorCode = "signatureInvalid"
|
||||
)
|
||||
@@ -0,0 +1,616 @@
|
||||
// Package manager contains plugin manager logic.
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/fs"
|
||||
"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/registry"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"github.com/grafana/grafana/pkg/util/errutil"
|
||||
)
|
||||
|
||||
var (
|
||||
DataSources map[string]*plugins.DataSourcePlugin
|
||||
Panels map[string]*plugins.PanelPlugin
|
||||
StaticRoutes []*plugins.PluginStaticRoute
|
||||
Apps map[string]*plugins.AppPlugin
|
||||
Plugins map[string]*plugins.PluginBase
|
||||
PluginTypes map[string]interface{}
|
||||
Renderer *plugins.RendererPlugin
|
||||
|
||||
plog log.Logger
|
||||
)
|
||||
|
||||
type unsignedPluginConditionFunc = func(plugin *plugins.PluginBase) bool
|
||||
|
||||
type PluginScanner struct {
|
||||
pluginPath string
|
||||
errors []error
|
||||
backendPluginManager backendplugin.Manager
|
||||
cfg *setting.Cfg
|
||||
requireSigned bool
|
||||
log log.Logger
|
||||
plugins map[string]*plugins.PluginBase
|
||||
allowUnsignedPluginsCondition unsignedPluginConditionFunc
|
||||
}
|
||||
|
||||
type PluginManager struct {
|
||||
BackendPluginManager backendplugin.Manager `inject:""`
|
||||
Cfg *setting.Cfg `inject:""`
|
||||
log log.Logger
|
||||
scanningErrors []error
|
||||
|
||||
// AllowUnsignedPluginsCondition changes the policy for allowing unsigned plugins. Signature validation only runs when plugins are starting
|
||||
// and running plugins will not be terminated if they violate the new policy.
|
||||
AllowUnsignedPluginsCondition unsignedPluginConditionFunc
|
||||
GrafanaLatestVersion string
|
||||
GrafanaHasUpdate bool
|
||||
pluginScanningErrors map[string]plugins.PluginError
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.RegisterService(&PluginManager{})
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Init() error {
|
||||
pm.log = log.New("plugins")
|
||||
plog = log.New("plugins")
|
||||
|
||||
DataSources = map[string]*plugins.DataSourcePlugin{}
|
||||
StaticRoutes = []*plugins.PluginStaticRoute{}
|
||||
Panels = map[string]*plugins.PanelPlugin{}
|
||||
Apps = map[string]*plugins.AppPlugin{}
|
||||
Plugins = map[string]*plugins.PluginBase{}
|
||||
PluginTypes = map[string]interface{}{
|
||||
"panel": plugins.PanelPlugin{},
|
||||
"datasource": plugins.DataSourcePlugin{},
|
||||
"app": plugins.AppPlugin{},
|
||||
"renderer": plugins.RendererPlugin{},
|
||||
}
|
||||
pm.pluginScanningErrors = map[string]plugins.PluginError{}
|
||||
|
||||
pm.log.Info("Starting plugin search")
|
||||
|
||||
plugDir := filepath.Join(pm.Cfg.StaticRootPath, "app/plugins")
|
||||
pm.log.Debug("Scanning core plugin directory", "dir", plugDir)
|
||||
if err := pm.scan(plugDir, false); err != nil {
|
||||
return errutil.Wrapf(err, "failed to scan core plugin directory '%s'", plugDir)
|
||||
}
|
||||
|
||||
plugDir = pm.Cfg.BundledPluginsPath
|
||||
pm.log.Debug("Scanning bundled plugins directory", "dir", plugDir)
|
||||
exists, err := fs.Exists(plugDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
if err := pm.scan(plugDir, false); err != nil {
|
||||
return errutil.Wrapf(err, "failed to scan bundled plugins directory '%s'", plugDir)
|
||||
}
|
||||
}
|
||||
|
||||
// check if plugins dir exists
|
||||
exists, err = fs.Exists(pm.Cfg.PluginsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
if err = os.MkdirAll(pm.Cfg.PluginsPath, os.ModePerm); err != nil {
|
||||
pm.log.Error("failed to create external plugins directory", "dir", pm.Cfg.PluginsPath, "error", err)
|
||||
} else {
|
||||
pm.log.Info("External plugins directory created", "directory", pm.Cfg.PluginsPath)
|
||||
}
|
||||
} else {
|
||||
pm.log.Debug("Scanning external plugins directory", "dir", pm.Cfg.PluginsPath)
|
||||
if err := pm.scan(pm.Cfg.PluginsPath, true); err != nil {
|
||||
return errutil.Wrapf(err, "failed to scan external plugins directory '%s'",
|
||||
pm.Cfg.PluginsPath)
|
||||
}
|
||||
}
|
||||
|
||||
if err := pm.scanPluginPaths(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, panel := range Panels {
|
||||
staticRoutes := panel.InitFrontendPlugin()
|
||||
StaticRoutes = append(StaticRoutes, staticRoutes...)
|
||||
}
|
||||
|
||||
for _, ds := range DataSources {
|
||||
staticRoutes := ds.InitFrontendPlugin()
|
||||
StaticRoutes = append(StaticRoutes, staticRoutes...)
|
||||
}
|
||||
|
||||
for _, app := range Apps {
|
||||
staticRoutes := app.InitApp(Panels, DataSources)
|
||||
StaticRoutes = append(StaticRoutes, staticRoutes...)
|
||||
}
|
||||
|
||||
if Renderer != nil {
|
||||
staticRoutes := Renderer.InitFrontendPlugin()
|
||||
StaticRoutes = append(StaticRoutes, staticRoutes...)
|
||||
}
|
||||
|
||||
for _, p := range Plugins {
|
||||
if p.IsCorePlugin {
|
||||
p.Signature = plugins.PluginSignatureInternal
|
||||
} else {
|
||||
metrics.SetPluginBuildInformation(p.Id, p.Type, p.Info.Version)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PluginManager) Run(ctx context.Context) error {
|
||||
pm.checkForUpdates()
|
||||
|
||||
ticker := time.NewTicker(time.Minute * 10)
|
||||
run := true
|
||||
|
||||
for run {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pm.checkForUpdates()
|
||||
case <-ctx.Done():
|
||||
run = false
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// scanPluginPaths scans configured plugin paths.
|
||||
func (pm *PluginManager) scanPluginPaths() error {
|
||||
for pluginID, settings := range pm.Cfg.PluginSettings {
|
||||
path, exists := settings["path"]
|
||||
if !exists || path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := pm.scan(path, true); err != nil {
|
||||
return errutil.Wrapf(err, "failed to scan directory configured for plugin '%s': '%s'", pluginID, path)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// scan a directory for plugins.
|
||||
func (pm *PluginManager) scan(pluginDir string, requireSigned bool) error {
|
||||
scanner := &PluginScanner{
|
||||
pluginPath: pluginDir,
|
||||
backendPluginManager: pm.BackendPluginManager,
|
||||
cfg: pm.Cfg,
|
||||
requireSigned: requireSigned,
|
||||
log: pm.log,
|
||||
plugins: map[string]*plugins.PluginBase{},
|
||||
allowUnsignedPluginsCondition: pm.AllowUnsignedPluginsCondition,
|
||||
}
|
||||
|
||||
// 1st pass: Scan plugins, also mapping plugins to their respective directories
|
||||
if err := util.Walk(pluginDir, true, true, scanner.walker); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
pm.log.Debug("Couldn't scan directory since it doesn't exist", "pluginDir", pluginDir, "err", err)
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
pm.log.Debug("Couldn't scan directory due to lack of permissions", "pluginDir", pluginDir, "err", err)
|
||||
return nil
|
||||
}
|
||||
if pluginDir != "data/plugins" {
|
||||
pm.log.Warn("Could not scan dir", "pluginDir", pluginDir, "err", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
pm.log.Debug("Initial plugin loading done")
|
||||
|
||||
// 2nd pass: Validate and register plugins
|
||||
for dpath, plugin := range scanner.plugins {
|
||||
// Try to find any root plugin
|
||||
ancestors := strings.Split(dpath, string(filepath.Separator))
|
||||
ancestors = ancestors[0 : len(ancestors)-1]
|
||||
aPath := ""
|
||||
if runtime.GOOS != "windows" && filepath.IsAbs(dpath) {
|
||||
aPath = "/"
|
||||
}
|
||||
for _, a := range ancestors {
|
||||
aPath = filepath.Join(aPath, a)
|
||||
if root, ok := scanner.plugins[aPath]; ok {
|
||||
plugin.Root = root
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pm.log.Debug("Found plugin", "id", plugin.Id, "signature", plugin.Signature, "hasRoot", plugin.Root != nil)
|
||||
signingError := scanner.validateSignature(plugin)
|
||||
if signingError != nil {
|
||||
pm.log.Debug("Failed to validate plugin signature. Will skip loading", "id", plugin.Id,
|
||||
"signature", plugin.Signature, "status", signingError.ErrorCode)
|
||||
pm.pluginScanningErrors[plugin.Id] = *signingError
|
||||
continue
|
||||
}
|
||||
|
||||
pm.log.Debug("Attempting to add plugin", "id", plugin.Id)
|
||||
|
||||
pluginGoType, exists := PluginTypes[plugin.Type]
|
||||
if !exists {
|
||||
return fmt.Errorf("unknown plugin type %q", plugin.Type)
|
||||
}
|
||||
|
||||
jsonFPath := filepath.Join(plugin.PluginDir, "plugin.json")
|
||||
|
||||
// External plugins need a module.js file for SystemJS to load
|
||||
if !strings.HasPrefix(jsonFPath, pm.Cfg.StaticRootPath) && !scanner.IsBackendOnlyPlugin(plugin.Type) {
|
||||
module := filepath.Join(plugin.PluginDir, "module.js")
|
||||
exists, err := fs.Exists(module)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
scanner.log.Warn("Plugin missing module.js",
|
||||
"name", plugin.Name,
|
||||
"warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.",
|
||||
"path", module)
|
||||
}
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `jsonFPath` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
reader, err := os.Open(jsonFPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
scanner.log.Warn("Failed to close JSON file", "path", jsonFPath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
jsonParser := json.NewDecoder(reader)
|
||||
|
||||
loader := reflect.New(reflect.TypeOf(pluginGoType)).Interface().(plugins.PluginLoader)
|
||||
|
||||
// Load the full plugin, and add it to manager
|
||||
if err := pm.loadPlugin(jsonParser, plugin, scanner, loader); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(scanner.errors) > 0 {
|
||||
pm.log.Warn("Some plugins failed to load", "errors", scanner.errors)
|
||||
pm.scanningErrors = scanner.errors
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pm *PluginManager) loadPlugin(jsonParser *json.Decoder, pluginBase *plugins.PluginBase,
|
||||
scanner *PluginScanner, loader plugins.PluginLoader) error {
|
||||
plug, err := loader.Load(jsonParser, pluginBase, scanner.backendPluginManager)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pb *plugins.PluginBase
|
||||
switch p := plug.(type) {
|
||||
case *plugins.DataSourcePlugin:
|
||||
DataSources[p.Id] = p
|
||||
pb = &p.PluginBase
|
||||
case *plugins.PanelPlugin:
|
||||
Panels[p.Id] = p
|
||||
pb = &p.PluginBase
|
||||
case *plugins.RendererPlugin:
|
||||
Renderer = p
|
||||
pb = &p.PluginBase
|
||||
case *plugins.AppPlugin:
|
||||
Apps[p.Id] = p
|
||||
pb = &p.PluginBase
|
||||
default:
|
||||
panic(fmt.Sprintf("Unrecognized plugin type %T", plug))
|
||||
}
|
||||
|
||||
if p, exists := Plugins[pb.Id]; exists {
|
||||
pm.log.Warn("Plugin is duplicate", "id", pb.Id)
|
||||
scanner.errors = append(scanner.errors, plugins.DuplicatePluginError{Plugin: pb, ExistingPlugin: p})
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(pluginBase.PluginDir, pm.Cfg.StaticRootPath) {
|
||||
pm.log.Info("Registering plugin", "id", pb.Id)
|
||||
}
|
||||
|
||||
if len(pb.Dependencies.Plugins) == 0 {
|
||||
pb.Dependencies.Plugins = []plugins.PluginDependencyItem{}
|
||||
}
|
||||
|
||||
if pb.Dependencies.GrafanaVersion == "" {
|
||||
pb.Dependencies.GrafanaVersion = "*"
|
||||
}
|
||||
|
||||
for _, include := range pb.Includes {
|
||||
if include.Role == "" {
|
||||
include.Role = models.ROLE_VIEWER
|
||||
}
|
||||
}
|
||||
|
||||
// Copy relevant fields from the base
|
||||
pb.PluginDir = pluginBase.PluginDir
|
||||
pb.Signature = pluginBase.Signature
|
||||
pb.SignatureType = pluginBase.SignatureType
|
||||
pb.SignatureOrg = pluginBase.SignatureOrg
|
||||
|
||||
Plugins[pb.Id] = pb
|
||||
pm.log.Debug("Successfully added plugin", "id", pb.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDatasource returns a datasource based on passed pluginID if it exists
|
||||
//
|
||||
// This function fetches the datasource from the global variable DataSources in this package.
|
||||
// Rather then refactor all dependencies on the global variable we can use this as an transition.
|
||||
func (pm *PluginManager) GetDatasource(pluginID string) (*plugins.DataSourcePlugin, bool) {
|
||||
ds, exists := DataSources[pluginID]
|
||||
return ds, exists
|
||||
}
|
||||
|
||||
func (s *PluginScanner) walker(currentPath string, f os.FileInfo, err error) error {
|
||||
// We scan all the subfolders for plugin.json (with some exceptions) so that we also load embedded plugins, for
|
||||
// example https://github.com/raintank/worldping-app/tree/master/dist/grafana-worldmap-panel worldmap panel plugin
|
||||
// is embedded in worldping app.
|
||||
if err != nil {
|
||||
return fmt.Errorf("filepath.Walk reported an error for %q: %w", currentPath, err)
|
||||
}
|
||||
|
||||
if f.Name() == "node_modules" || f.Name() == "Chromium.app" {
|
||||
return util.ErrWalkSkipDir
|
||||
}
|
||||
|
||||
if f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if f.Name() != "plugin.json" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.loadPlugin(currentPath); err != nil {
|
||||
s.log.Error("Failed to load plugin", "error", err, "pluginPath", filepath.Dir(currentPath))
|
||||
s.errors = append(s.errors, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PluginScanner) loadPlugin(pluginJSONFilePath string) error {
|
||||
s.log.Debug("Loading plugin", "path", pluginJSONFilePath)
|
||||
currentDir := filepath.Dir(pluginJSONFilePath)
|
||||
// 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(pluginJSONFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := reader.Close(); err != nil {
|
||||
s.log.Warn("Failed to close JSON file", "path", pluginJSONFilePath, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
jsonParser := json.NewDecoder(reader)
|
||||
pluginCommon := plugins.PluginBase{}
|
||||
if err := jsonParser.Decode(&pluginCommon); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pluginCommon.Id == "" || pluginCommon.Type == "" {
|
||||
return errors.New("did not find type or id properties in plugin.json")
|
||||
}
|
||||
|
||||
pluginCommon.PluginDir = filepath.Dir(pluginJSONFilePath)
|
||||
pluginCommon.Files, err = collectPluginFilesWithin(pluginCommon.PluginDir)
|
||||
if err != nil {
|
||||
s.log.Warn("Could not collect plugin file information in directory", "pluginID", pluginCommon.Id, "dir", pluginCommon.PluginDir)
|
||||
return err
|
||||
}
|
||||
|
||||
signatureState, err := getPluginSignatureState(s.log, &pluginCommon)
|
||||
if err != nil {
|
||||
s.log.Warn("Could not get plugin signature state", "pluginID", pluginCommon.Id, "err", err)
|
||||
return err
|
||||
}
|
||||
pluginCommon.Signature = signatureState.Status
|
||||
pluginCommon.SignatureType = signatureState.Type
|
||||
pluginCommon.SignatureOrg = signatureState.SigningOrg
|
||||
|
||||
s.plugins[currentDir] = &pluginCommon
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*PluginScanner) IsBackendOnlyPlugin(pluginType string) bool {
|
||||
return pluginType == "renderer"
|
||||
}
|
||||
|
||||
// validateSignature validates a plugin's signature.
|
||||
func (s *PluginScanner) validateSignature(plugin *plugins.PluginBase) *plugins.PluginError {
|
||||
if plugin.Signature == plugins.PluginSignatureValid {
|
||||
s.log.Debug("Plugin has valid signature", "id", plugin.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
if plugin.Root != nil {
|
||||
// If a descendant plugin with invalid signature, set signature to that of root
|
||||
if plugin.IsCorePlugin || plugin.Signature == plugins.PluginSignatureInternal {
|
||||
s.log.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 {
|
||||
s.log.Debug("Setting descendant plugin's signature to that of root", "plugin", plugin.Id,
|
||||
"root", plugin.Root.Id, "signature", plugin.Signature, "rootSignature", plugin.Root.Signature)
|
||||
plugin.Signature = plugin.Root.Signature
|
||||
if plugin.Signature == plugins.PluginSignatureValid {
|
||||
s.log.Debug("Plugin has valid signature (inherited from root)", "id", plugin.Id)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.log.Debug("Non-valid plugin Signature", "pluginID", plugin.Id, "pluginDir", plugin.PluginDir,
|
||||
"state", plugin.Signature)
|
||||
}
|
||||
|
||||
// For the time being, we choose to only require back-end plugins to be signed
|
||||
// NOTE: the state is calculated again when setting metadata on the object
|
||||
if !plugin.Backend || !s.requireSigned {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch plugin.Signature {
|
||||
case plugins.PluginSignatureUnsigned:
|
||||
if allowed := s.allowUnsigned(plugin); !allowed {
|
||||
s.log.Debug("Plugin is unsigned", "id", plugin.Id)
|
||||
s.errors = append(s.errors, fmt.Errorf("plugin %q is unsigned", plugin.Id))
|
||||
return &plugins.PluginError{
|
||||
ErrorCode: signatureMissing,
|
||||
}
|
||||
}
|
||||
s.log.Warn("Running an unsigned backend plugin", "pluginID", plugin.Id, "pluginDir",
|
||||
plugin.PluginDir)
|
||||
return nil
|
||||
case plugins.PluginSignatureInvalid:
|
||||
s.log.Debug("Plugin %q has an invalid signature", plugin.Id)
|
||||
s.errors = append(s.errors, fmt.Errorf("plugin %q has an invalid signature", plugin.Id))
|
||||
return &plugins.PluginError{
|
||||
ErrorCode: signatureInvalid,
|
||||
}
|
||||
case plugins.PluginSignatureModified:
|
||||
s.log.Debug("Plugin %q has a modified signature", plugin.Id)
|
||||
s.errors = append(s.errors, fmt.Errorf("plugin %q's signature has been modified", plugin.Id))
|
||||
return &plugins.PluginError{
|
||||
ErrorCode: signatureModified,
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("Plugin %q has unrecognized plugin signature state %q", plugin.Id, plugin.Signature))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PluginScanner) allowUnsigned(plugin *plugins.PluginBase) bool {
|
||||
if s.allowUnsignedPluginsCondition != nil {
|
||||
return s.allowUnsignedPluginsCondition(plugin)
|
||||
}
|
||||
|
||||
if s.cfg.Env == setting.Dev {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, plug := range s.cfg.PluginsAllowUnsigned {
|
||||
if plug == plugin.Id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ScanningErrors returns plugin scanning errors encountered.
|
||||
func (pm *PluginManager) ScanningErrors() []plugins.PluginError {
|
||||
scanningErrs := make([]plugins.PluginError, 0)
|
||||
for id, e := range pm.pluginScanningErrors {
|
||||
scanningErrs = append(scanningErrs, plugins.PluginError{
|
||||
ErrorCode: e.ErrorCode,
|
||||
PluginID: id,
|
||||
})
|
||||
}
|
||||
return scanningErrs
|
||||
}
|
||||
|
||||
func (pm *PluginManager) GetPluginMarkdown(pluginId string, name string) ([]byte, error) {
|
||||
plug, exists := Plugins[pluginId]
|
||||
if !exists {
|
||||
return nil, plugins.PluginNotFoundError{PluginID: pluginId}
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plug.PluginDir` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
path := filepath.Join(plug.PluginDir, fmt.Sprintf("%s.md", strings.ToUpper(name)))
|
||||
exists, err := fs.Exists(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
path = filepath.Join(plug.PluginDir, fmt.Sprintf("%s.md", strings.ToLower(name)))
|
||||
}
|
||||
|
||||
exists, err = fs.Exists(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return make([]byte, 0), nil
|
||||
}
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `plug.PluginDir` is based
|
||||
// on plugin the folder structure on disk and not user input.
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// gets plugin filenames that require verification for plugin signing
|
||||
func collectPluginFilesWithin(rootDir string) ([]string, error) {
|
||||
var files []string
|
||||
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && info.Name() != "MANIFEST.txt" {
|
||||
file, err := filepath.Rel(rootDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, filepath.ToSlash(file))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetDataPlugin gets a DataPlugin with a certain name. If none is found, nil is returned.
|
||||
func (pm *PluginManager) GetDataPlugin(pluginID string) plugins.DataPlugin {
|
||||
if p, exists := DataSources[pluginID]; exists && p.CanHandleDataQueries() {
|
||||
return p
|
||||
}
|
||||
|
||||
// XXX: Might other plugins implement DataPlugin?
|
||||
|
||||
p := pm.BackendPluginManager.GetDataPlugin(pluginID)
|
||||
if p != nil {
|
||||
return p.(plugins.DataPlugin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"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"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
func TestPluginManager_Init(t *testing.T) {
|
||||
staticRootPath, err := filepath.Abs("../../../public/")
|
||||
require.NoError(t, err)
|
||||
|
||||
origRootPath := setting.StaticRootPath
|
||||
origRaw := setting.Raw
|
||||
origEnv := setting.Env
|
||||
t.Cleanup(func() {
|
||||
setting.StaticRootPath = origRootPath
|
||||
setting.Raw = origRaw
|
||||
setting.Env = origEnv
|
||||
})
|
||||
setting.StaticRootPath = staticRootPath
|
||||
setting.Raw = ini.Empty()
|
||||
setting.Env = setting.Prod
|
||||
|
||||
t.Run("Base case", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
Raw: ini.Empty(),
|
||||
Env: setting.Prod,
|
||||
StaticRootPath: staticRootPath,
|
||||
PluginSettings: setting.PluginSettings{
|
||||
"nginx-app": map[string]string{
|
||||
"path": "testdata/test-app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, pm.scanningErrors)
|
||||
assert.Greater(t, len(DataSources), 1)
|
||||
assert.Greater(t, len(Panels), 1)
|
||||
assert.Equal(t, "app/plugins/datasource/graphite/module", DataSources["graphite"].Module)
|
||||
assert.NotEmpty(t, Apps)
|
||||
assert.Equal(t, "public/plugins/test-app/img/logo_large.png", Apps["test-app"].Info.Logos.Large)
|
||||
assert.Equal(t, "public/plugins/test-app/img/screenshot2.png", Apps["test-app"].Info.Screenshots[1].Path)
|
||||
})
|
||||
|
||||
t.Run("With external back-end plugin lacking signature", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{PluginsPath: "testdata/unsigned"},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []error{fmt.Errorf(`plugin "test" is unsigned`)}, pm.scanningErrors)
|
||||
})
|
||||
|
||||
t.Run("With external unsigned back-end plugin and configuration disabling signature check of this plugin", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/unsigned",
|
||||
PluginsAllowUnsigned: []string{"test"},
|
||||
},
|
||||
BackendPluginManager: &fakeBackendPluginManager{},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, pm.scanningErrors)
|
||||
})
|
||||
|
||||
t.Run("With external back-end plugin with invalid v1 signature", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/invalid-v1-signature",
|
||||
},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []error{fmt.Errorf(`plugin "test" has an invalid signature`)}, pm.scanningErrors)
|
||||
})
|
||||
|
||||
t.Run("With external back-end plugin lacking files listed in manifest", func(t *testing.T) {
|
||||
fm := &fakeBackendPluginManager{}
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/lacking-files",
|
||||
},
|
||||
BackendPluginManager: fm,
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []error{fmt.Errorf(`plugin "test"'s signature has been modified`)}, pm.scanningErrors)
|
||||
})
|
||||
|
||||
t.Run("Transform plugins should be ignored when expressions feature is off", func(t *testing.T) {
|
||||
fm := fakeBackendPluginManager{}
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/behind-feature-flag",
|
||||
},
|
||||
BackendPluginManager: &fm,
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, pm.scanningErrors)
|
||||
assert.Empty(t, fm.registeredPlugins)
|
||||
})
|
||||
|
||||
t.Run("With nested plugin duplicating parent", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/duplicate-plugins",
|
||||
},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, pm.scanningErrors, 1)
|
||||
assert.True(t, errors.Is(pm.scanningErrors[0], plugins.DuplicatePluginError{}))
|
||||
})
|
||||
|
||||
t.Run("With external back-end plugin with valid v2 signature", func(t *testing.T) {
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/valid-v2-signature",
|
||||
},
|
||||
BackendPluginManager: &fakeBackendPluginManager{},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, pm.scanningErrors)
|
||||
|
||||
pluginId := "test"
|
||||
assert.NotNil(t, Plugins[pluginId])
|
||||
assert.Equal(t, "datasource", Plugins[pluginId].Type)
|
||||
assert.Equal(t, "Test", Plugins[pluginId].Name)
|
||||
assert.Equal(t, pluginId, Plugins[pluginId].Id)
|
||||
assert.Equal(t, "1.0.0", Plugins[pluginId].Info.Version)
|
||||
assert.Equal(t, plugins.PluginSignatureValid, Plugins[pluginId].Signature)
|
||||
assert.Equal(t, plugins.GrafanaType, Plugins[pluginId].SignatureType)
|
||||
assert.Equal(t, "Grafana Labs", Plugins[pluginId].SignatureOrg)
|
||||
assert.False(t, Plugins[pluginId].IsCorePlugin)
|
||||
})
|
||||
|
||||
t.Run("With back-end plugin with invalid v2 private signature (mismatched root URL)", func(t *testing.T) {
|
||||
origAppURL := setting.AppUrl
|
||||
t.Cleanup(func() {
|
||||
setting.AppUrl = origAppURL
|
||||
})
|
||||
setting.AppUrl = "http://localhost:1234"
|
||||
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/valid-v2-pvt-signature",
|
||||
},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, []error{fmt.Errorf(`plugin "test" has an invalid signature`)}, pm.scanningErrors)
|
||||
assert.Nil(t, Plugins[("test")])
|
||||
})
|
||||
|
||||
t.Run("With back-end plugin with valid v2 private signature", func(t *testing.T) {
|
||||
origAppURL := setting.AppUrl
|
||||
t.Cleanup(func() {
|
||||
setting.AppUrl = origAppURL
|
||||
})
|
||||
setting.AppUrl = "http://localhost:3000/"
|
||||
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/valid-v2-pvt-signature",
|
||||
},
|
||||
BackendPluginManager: &fakeBackendPluginManager{},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, pm.scanningErrors)
|
||||
|
||||
pluginId := "test"
|
||||
assert.NotNil(t, Plugins[pluginId])
|
||||
assert.Equal(t, "datasource", Plugins[pluginId].Type)
|
||||
assert.Equal(t, "Test", Plugins[pluginId].Name)
|
||||
assert.Equal(t, pluginId, Plugins[pluginId].Id)
|
||||
assert.Equal(t, "1.0.0", Plugins[pluginId].Info.Version)
|
||||
assert.Equal(t, plugins.PluginSignatureValid, Plugins[pluginId].Signature)
|
||||
assert.Equal(t, plugins.PrivateType, Plugins[pluginId].SignatureType)
|
||||
assert.Equal(t, "Will Browne", Plugins[pluginId].SignatureOrg)
|
||||
assert.False(t, Plugins[pluginId].IsCorePlugin)
|
||||
})
|
||||
|
||||
t.Run("With back-end plugin with modified v2 signature (missing file from plugin dir)", func(t *testing.T) {
|
||||
origAppURL := setting.AppUrl
|
||||
t.Cleanup(func() {
|
||||
setting.AppUrl = origAppURL
|
||||
})
|
||||
setting.AppUrl = "http://localhost:3000/"
|
||||
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/invalid-v2-signature",
|
||||
},
|
||||
BackendPluginManager: &fakeBackendPluginManager{},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []error{fmt.Errorf(`plugin "test"'s signature has been modified`)}, pm.scanningErrors)
|
||||
assert.Nil(t, Plugins[("test")])
|
||||
})
|
||||
|
||||
t.Run("With back-end plugin with modified v2 signature (unaccounted file in plugin dir)", func(t *testing.T) {
|
||||
origAppURL := setting.AppUrl
|
||||
t.Cleanup(func() {
|
||||
setting.AppUrl = origAppURL
|
||||
})
|
||||
setting.AppUrl = "http://localhost:3000/"
|
||||
|
||||
pm := &PluginManager{
|
||||
Cfg: &setting.Cfg{
|
||||
PluginsPath: "testdata/invalid-v2-signature-2",
|
||||
},
|
||||
BackendPluginManager: &fakeBackendPluginManager{},
|
||||
}
|
||||
err := pm.Init()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []error{fmt.Errorf(`plugin "test"'s signature has been modified`)}, pm.scanningErrors)
|
||||
assert.Nil(t, Plugins[("test")])
|
||||
})
|
||||
}
|
||||
|
||||
func TestPluginManager_IsBackendOnlyPlugin(t *testing.T) {
|
||||
pluginScanner := &PluginScanner{}
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
isBackendOnly bool
|
||||
}
|
||||
|
||||
for _, c := range []testCase{
|
||||
{name: "renderer", isBackendOnly: true},
|
||||
{name: "app", isBackendOnly: false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("Plugin %s", c.name), func(t *testing.T) {
|
||||
result := pluginScanner.IsBackendOnlyPlugin(c.name)
|
||||
|
||||
assert.Equal(t, c.isBackendOnly, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBackendPluginManager struct {
|
||||
backendplugin.Manager
|
||||
|
||||
registeredPlugins []string
|
||||
}
|
||||
|
||||
func (f *fakeBackendPluginManager) Register(pluginID string, factory backendplugin.PluginFactoryFunc) error {
|
||||
f.registeredPlugins = append(f.registeredPlugins, pluginID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBackendPluginManager) StartPlugin(ctx context.Context, pluginID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBackendPluginManager) CollectMetrics(ctx context.Context, pluginID string) (*backend.CollectMetricsResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeBackendPluginManager) CheckHealth(ctx context.Context, pCtx backend.PluginContext) (*backend.CheckHealthResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeBackendPluginManager) CallResource(pluginConfig backend.PluginContext, ctx *models.ReqContext, path string) {
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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:
|
||||
// https://grafana.com/api/plugins/ci/keys
|
||||
const publicKeyText = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
xpMEXpTXXxMFK4EEACMEIwQBiOUQhvGbDLvndE0fEXaR0908wXzPGFpf0P0Z
|
||||
HJ06tsq+0higIYHp7WTNJVEZtcwoYLcPRGaa9OQqbUU63BEyZdgAkPTz3RFd
|
||||
5+TkDWZizDcaVFhzbDd500yTwexrpIrdInwC/jrgs7Zy/15h8KA59XXUkdmT
|
||||
YB6TR+OA9RKME+dCJozNGUdyYWZhbmEgPGVuZ0BncmFmYW5hLmNvbT7CvAQQ
|
||||
EwoAIAUCXpTXXwYLCQcIAwIEFQgKAgQWAgEAAhkBAhsDAh4BAAoJEH5NDGpw
|
||||
iGbnaWoCCQGQ3SQnCkRWrG6XrMkXOKfDTX2ow9fuoErN46BeKmLM4f1EkDZQ
|
||||
Tpq3SE8+My8B5BIH3SOcBeKzi3S57JHGBdFA+wIJAYWMrJNIvw8GeXne+oUo
|
||||
NzzACdvfqXAZEp/HFMQhCKfEoWGJE8d2YmwY2+3GufVRTI5lQnZOHLE8L/Vc
|
||||
1S5MXESjzpcEXpTXXxIFK4EEACMEIwQBtHX/SD5Qm3v4V92qpaIZQgtTX0sT
|
||||
cFPjYWAHqsQ1iENrYN/vg1wU3ADlYATvydOQYvkTyT/tbDvx2Fse8PL84MQA
|
||||
YKKQ6AJ3gLVvmeouZdU03YoV4MYaT8KbnJUkZQZkqdz2riOlySNI9CG3oYmv
|
||||
omjUAtzgAgnCcurfGLZkkMxlmY8DAQoJwqQEGBMKAAkFAl6U118CGwwACgkQ
|
||||
fk0ManCIZuc0jAIJAVw2xdLr4ZQqPUhubrUyFcqlWoW8dQoQagwO8s8ubmby
|
||||
KuLA9FWJkfuuRQr+O9gHkDVCez3aism7zmJBqIOi38aNAgjJ3bo6leSS2jR/
|
||||
x5NqiKVi83tiXDPncDQYPymOnMhW0l7CVA7wj75HrFvvlRI/4MArlbsZ2tBn
|
||||
N1c5v9v/4h6qeA==
|
||||
=DNbR
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
`
|
||||
|
||||
// pluginManifest holds details for the file manifest
|
||||
type pluginManifest struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Version string `json:"version"`
|
||||
KeyID string `json:"keyId"`
|
||||
Time int64 `json:"time"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (m *pluginManifest) isV2() bool {
|
||||
return strings.HasPrefix(m.ManifestVersion, "2.")
|
||||
}
|
||||
|
||||
// readPluginManifest attempts to read and verify the plugin manifest
|
||||
// if any error occurs or the manifest is not valid, this will return an error
|
||||
func readPluginManifest(body []byte) (*pluginManifest, error) {
|
||||
block, _ := clearsign.Decode(body)
|
||||
if block == nil {
|
||||
return nil, errors.New("unable to decode manifest")
|
||||
}
|
||||
|
||||
// Convert to a well typed object
|
||||
manifest := &pluginManifest{}
|
||||
err := json.Unmarshal(block.Plaintext, &manifest)
|
||||
if err != nil {
|
||||
return nil, errutil.Wrap("Error parsing manifest JSON", err)
|
||||
}
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewBufferString(publicKeyText))
|
||||
if err != nil {
|
||||
return nil, errutil.Wrap("failed to parse public key", err)
|
||||
}
|
||||
|
||||
if _, err := openpgp.CheckDetachedSignature(keyring,
|
||||
bytes.NewBuffer(block.Bytes),
|
||||
block.ArmoredSignature.Body); err != nil {
|
||||
return nil, errutil.Wrap("failed to check signature", err)
|
||||
}
|
||||
|
||||
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)
|
||||
manifestPath := filepath.Join(plugin.PluginDir, "MANIFEST.txt")
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `manifestPath` is based
|
||||
// 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,
|
||||
}, nil
|
||||
}
|
||||
|
||||
manifest, err := readPluginManifest(byteValue)
|
||||
if err != nil {
|
||||
log.Debug("Plugin signature invalid", "id", plugin.Id)
|
||||
return plugins.PluginSignatureState{
|
||||
Status: plugins.PluginSignatureInvalid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Make sure the versions all match
|
||||
if manifest.Plugin != plugin.Id || manifest.Version != plugin.Info.Version {
|
||||
return plugins.PluginSignatureState{
|
||||
Status: plugins.PluginSignatureModified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate that private is running within defined root URLs
|
||||
if manifest.SignatureType == plugins.PrivateType {
|
||||
appURL, err := url.Parse(setting.AppUrl)
|
||||
if err != nil {
|
||||
return plugins.PluginSignatureState{}, err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if rootURL.Scheme == appURL.Scheme &&
|
||||
rootURL.Host == appURL.Host &&
|
||||
rootURL.RequestURI() == appURL.RequestURI() {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
manifestFiles := make(map[string]bool, len(manifest.Files))
|
||||
|
||||
// Verify the manifest contents
|
||||
log.Debug("Verifying contents of plugin manifest", "plugin", plugin.Id)
|
||||
for p, hash := range manifest.Files {
|
||||
// Open the file
|
||||
fp := filepath.Join(plugin.PluginDir, p)
|
||||
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning on this one because `fp` is based
|
||||
// on the manifest file for a plugin and not user input.
|
||||
f, err := os.Open(fp)
|
||||
if err != nil {
|
||||
log.Warn("Plugin file listed in the manifest was not found", "plugin", plugin.Id, "filename", p, "dir", plugin.PluginDir)
|
||||
return plugins.PluginSignatureState{
|
||||
Status: plugins.PluginSignatureModified,
|
||||
}, nil
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
log.Warn("Failed to close plugin file", "path", fp, "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
log.Warn("Couldn't read plugin file", "plugin", plugin.Id, "filename", fp)
|
||||
return plugins.PluginSignatureState{
|
||||
Status: plugins.PluginSignatureModified,
|
||||
}, nil
|
||||
}
|
||||
sum := hex.EncodeToString(h.Sum(nil))
|
||||
if sum != hash {
|
||||
log.Warn("Plugin file's signature has been modified versus manifest", "plugin", plugin.Id, "filename", fp)
|
||||
return plugins.PluginSignatureState{
|
||||
Status: plugins.PluginSignatureModified,
|
||||
}, nil
|
||||
}
|
||||
manifestFiles[p] = true
|
||||
}
|
||||
|
||||
if manifest.isV2() {
|
||||
// Track files missing from the manifest
|
||||
var unsignedFiles []string
|
||||
for _, f := range plugin.Files {
|
||||
if _, exists := manifestFiles[f]; !exists {
|
||||
unsignedFiles = append(unsignedFiles, f)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Everything OK
|
||||
log.Debug("Plugin signature valid", "id", plugin.Id)
|
||||
return plugins.PluginSignatureState{
|
||||
Status: plugins.PluginSignatureValid,
|
||||
Type: manifest.SignatureType,
|
||||
SigningOrg: manifest.SignedByOrgName,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadPluginManifest(t *testing.T) {
|
||||
txt := `-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"plugin": "grafana-googlesheets-datasource",
|
||||
"version": "1.0.0-dev",
|
||||
"files": {
|
||||
"LICENSE": "7df059597099bb7dcf25d2a9aedfaf4465f72d8d",
|
||||
"README.md": "08ec6d704b6115bef57710f6d7e866c050cb50ee",
|
||||
"gfx_sheets_darwin_amd64": "1b8ae92c6e80e502bb0bf2d0ae9d7223805993ab",
|
||||
"gfx_sheets_linux_amd64": "f39e0cc7344d3186b1052e6d356eecaf54d75b49",
|
||||
"gfx_sheets_windows_amd64.exe": "c8825dfec512c1c235244f7998ee95182f9968de",
|
||||
"module.js": "aaec6f51a995b7b843b843cd14041925274d960d",
|
||||
"module.js.LICENSE.txt": "7f822fe9341af8f82ad1b0c69aba957822a377cf",
|
||||
"module.js.map": "c5a524f5c4237f6ed6a016d43cd46938efeadb45",
|
||||
"plugin.json": "55556b845e91935cc48fae3aa67baf0f22694c3f"
|
||||
},
|
||||
"time": 1586817677115,
|
||||
"keyId": "7e4d0c6a708866e7"
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqEEARMKAAYFAl6U6o0ACgkQfk0ManCIZuevWAIHSvcxOy1SvvL5gC+HpYyG
|
||||
VbSsUvF2FsCoXUCTQflK6VdJfSPNzm8YdCdx7gNrBdly6HEs06ZaRp44F/ve
|
||||
NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
|
||||
74uuP5UXZxwK2YKJovdSknDIU7MhfuvvQIP/og==
|
||||
=hBea
|
||||
-----END PGP SIGNATURE-----`
|
||||
|
||||
t.Run("valid manifest", func(t *testing.T) {
|
||||
manifest, err := readPluginManifest([]byte(txt))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest)
|
||||
assert.Equal(t, "grafana-googlesheets-datasource", manifest.Plugin)
|
||||
assert.Equal(t, "1.0.0-dev", manifest.Version)
|
||||
assert.Equal(t, int64(1586817677115), manifest.Time)
|
||||
assert.Equal(t, "7e4d0c6a708866e7", manifest.KeyID)
|
||||
expectedFiles := []string{"LICENSE", "README.md", "gfx_sheets_darwin_amd64", "gfx_sheets_linux_amd64",
|
||||
"gfx_sheets_windows_amd64.exe", "module.js", "module.js.LICENSE.txt", "module.js.map", "plugin.json",
|
||||
}
|
||||
assert.Equal(t, expectedFiles, fileList(manifest))
|
||||
})
|
||||
|
||||
t.Run("invalid manifest", func(t *testing.T) {
|
||||
modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx")
|
||||
_, err := readPluginManifest([]byte(modified))
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestReadPluginManifestV2(t *testing.T) {
|
||||
txt := `-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "private",
|
||||
"signedByOrg": "willbrowne",
|
||||
"signedByOrgName": "Will Browne",
|
||||
"rootUrls": [
|
||||
"http://localhost:3000/"
|
||||
],
|
||||
"plugin": "test",
|
||||
"version": "1.0.0",
|
||||
"time": 1605807018050,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqIEARMKAAYFAl+2q6oACgkQfk0ManCIZudmzwIJAXWz58cd/91rTXszKPnE
|
||||
xbVEvERCbjKTtPBQBNQyqEvV+Ig3MuBSNOVy2SOGrMsdbS6lONgvgt4Cm+iS
|
||||
wV+vYifkAgkBJtg/9DMB7/iX5O0h49CtSltcpfBFXlGqIeOwRac/yENzRzAA
|
||||
khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
|
||||
=rLIE
|
||||
-----END PGP SIGNATURE-----`
|
||||
|
||||
t.Run("valid manifest", func(t *testing.T) {
|
||||
manifest, err := readPluginManifest([]byte(txt))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, manifest)
|
||||
assert.Equal(t, "test", manifest.Plugin)
|
||||
assert.Equal(t, "1.0.0", manifest.Version)
|
||||
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, "willbrowne", manifest.SignedByOrg)
|
||||
assert.Equal(t, "Will Browne", manifest.SignedByOrgName)
|
||||
assert.Equal(t, []string{"http://localhost:3000/"}, manifest.RootURLs)
|
||||
assert.Equal(t, []string{"plugin.json"}, fileList(manifest))
|
||||
})
|
||||
}
|
||||
|
||||
func fileList(manifest *pluginManifest) []string {
|
||||
var keys []string
|
||||
for k := range manifest.Files {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
)
|
||||
|
||||
func (pm *PluginManager) GetPluginSettings(orgId int64) (map[string]*models.PluginSettingInfoDTO, error) {
|
||||
query := models.GetPluginSettingsQuery{OrgId: orgId}
|
||||
if err := bus.Dispatch(&query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pluginMap := make(map[string]*models.PluginSettingInfoDTO)
|
||||
for _, plug := range query.Result {
|
||||
pluginMap[plug.PluginId] = plug
|
||||
}
|
||||
|
||||
for _, pluginDef := range 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 := 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 pluginID, app := range Apps {
|
||||
if b, ok := pluginSettingMap[pluginID]; 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 DataSources {
|
||||
if _, exists := pluginSettingMap[ds.Id]; exists {
|
||||
enabledPlugins.DataSources[dsID] = ds
|
||||
}
|
||||
}
|
||||
|
||||
for _, panel := range 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 IsAppInstalled(pluginID string) bool {
|
||||
_, exists := Apps[pluginID]
|
||||
return exists
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Child",
|
||||
"id": "test-app",
|
||||
"info": {
|
||||
"description": "Child plugin",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "http://grafana.com"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"updated": "2020-10-20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Parent",
|
||||
"id": "test-app",
|
||||
"info": {
|
||||
"description": "Parent plugin",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "http://grafana.com"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"updated": "2020-10-20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Invalid manifest
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "grafana",
|
||||
"signedByOrg": "grafana",
|
||||
"signedByOrgName": "Grafana Labs",
|
||||
"plugin": "test",
|
||||
"version": "1.0.0",
|
||||
"time": 1605807330546,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqEEARMKAAYFAl+2rOIACgkQfk0ManCIZudNOwIJAT8FTzwnRFCSLTOaR3F3
|
||||
2Fh96eRbghokXcQG9WqpQAg8ZiVfGXeWWRNtV+nuQ9VOZOTO0BovWLuMkym2
|
||||
ci8ABpWOAgd46LkGn3Dd8XVnGmLI6UPqHAXflItOrCMRiGcYJn5PxP1aCz8h
|
||||
D0JoNI9TIKrhMtM4voU3Qhf3mIOTHueuDNS48w==
|
||||
=mu2j
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Will Browne",
|
||||
"url": "https://willbrowne.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "grafana",
|
||||
"signedByOrg": "grafana",
|
||||
"signedByOrgName": "Grafana Labs",
|
||||
"plugin": "test",
|
||||
"version": "1.0.0",
|
||||
"time": 1605809299800,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f",
|
||||
"veryImportantFile": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqIEARMKAAYFAl+2tJMACgkQfk0ManCIZueB0AIJAT/PWs226MaIu3eDZy4o
|
||||
3UH/tIExyY4zR+VSBfTS+Gji5BcIRkIn7bhM1U40KDraDCvQOl3WetgqQkPd
|
||||
wcSTJJocAgkBrsrxNz/Nl+vw/usre3Funj0hPVS/6NnJXwe6sVH+gAQfeddz
|
||||
MzYTY/gcUVWp8Y7l/Hg44nry0PS3sr5LQ30w/FY=
|
||||
=ev+T
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Will Browne",
|
||||
"url": "https://willbrowne.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"plugin": "test",
|
||||
"version": "1.0.0",
|
||||
"files": {
|
||||
"executable": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"plugin.json": "f19e18ca5dec690b94f2ff7866372db4bc63f4ff1101ee5d9061b9a6f026f6dd"
|
||||
},
|
||||
"time": 1589270570251,
|
||||
"keyId": "7e4d0c6a708866e7"
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqIEARMKAAYFAl66WCoACgkQfk0ManCIZuf8zwIJAWBVDJyzqchZ/DN7ZDCy
|
||||
Kyb63CajW/XdgHalPDB0sZ/80ExjCSprkFYPL+hUlTUHIXh+jGkfnYpMoqWA
|
||||
Om77bFc2AgkBV8HTsuRw/lXUezKnDuXcgUIvHvEwKWTvtbLgcuMXMDAVAEBj
|
||||
isBWmA8xvUfMzgQ9CJUHAaJ6hf1erpE4BuBqtMM=
|
||||
=uBIX
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"info": {
|
||||
"description": "Test",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_NAME",
|
||||
"type": "datasource",
|
||||
"pluginId": "graphite"
|
||||
}
|
||||
],
|
||||
|
||||
"uid": "1MHHlVjzz",
|
||||
"title": "Nginx Connections",
|
||||
"revision": 25,
|
||||
"schemaVersion": 11,
|
||||
"tags": ["tag1", "tag2"],
|
||||
"number_array": [1,2,3,10.33],
|
||||
"boolean_true": true,
|
||||
"boolean_false": false,
|
||||
|
||||
"rows": [
|
||||
{
|
||||
"panels": [
|
||||
{
|
||||
"type": "graph",
|
||||
"datasource": "${DS_NAME}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"revision": 25,
|
||||
"tags": ["tag1", "tag2"],
|
||||
"boolean_false": false,
|
||||
"boolean_true": true,
|
||||
"number_array": [1,2,3,10.33],
|
||||
"rows": [
|
||||
{
|
||||
"panels": [
|
||||
{
|
||||
"datasource": "graphite",
|
||||
"type": "graph"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"schemaVersion": 11,
|
||||
"title": "Nginx Connections",
|
||||
"uid": "1MHHlVjzz",
|
||||
"version": 0
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Nginx Memory",
|
||||
"revision": 2,
|
||||
"schemaVersion": 11
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"type": "app",
|
||||
"name": "Test App",
|
||||
"id": "test-app",
|
||||
|
||||
"staticRoot": ".",
|
||||
|
||||
"pages": [
|
||||
{ "name": "Live stream", "component": "StreamPageCtrl", "role": "Editor"},
|
||||
{ "name": "Log view", "component": "LogsPageCtrl", "role": "Viewer"}
|
||||
],
|
||||
|
||||
"css": {
|
||||
"dark": "css/dark.css",
|
||||
"light": "css/light.css"
|
||||
},
|
||||
|
||||
"info": {
|
||||
"description": "Official Grafana Test App & Dashboard bundle",
|
||||
"author": {
|
||||
"name": "Test Inc.",
|
||||
"url": "http://test.com"
|
||||
},
|
||||
"keywords": ["test"],
|
||||
"logos": {
|
||||
"small": "img/logo_small.png",
|
||||
"large": "img/logo_large.png"
|
||||
},
|
||||
"screenshots": [
|
||||
{"name": "img1", "path": "img/screenshot1.png"},
|
||||
{"name": "img2", "path": "img/screenshot2.png"}
|
||||
],
|
||||
"links": [
|
||||
{"name": "Project site", "url": "http://project.com"},
|
||||
{"name": "License & Terms", "url": "http://license.com"}
|
||||
],
|
||||
"version": "1.0.0",
|
||||
"updated": "2015-02-10"
|
||||
},
|
||||
|
||||
"includes": [
|
||||
{"type": "dashboard", "name": "Nginx Connections", "path": "dashboards/connections.json"},
|
||||
{"type": "dashboard", "name": "Nginx Memory", "path": "dashboards/memory.json"},
|
||||
{"type": "panel", "name": "Nginx Panel"},
|
||||
{"type": "datasource", "name": "Nginx Datasource"}
|
||||
],
|
||||
|
||||
"dependencies": {
|
||||
"grafanaVersion": "3.x.x",
|
||||
"plugins": [
|
||||
{"type": "datasource", "id": "graphite", "name": "Graphite", "version": "1.0.0"},
|
||||
{"type": "panel", "id": "graph", "name": "Graph", "version": "1.0.0"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Grafana Labs",
|
||||
"url": "https://grafana.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "private",
|
||||
"signedByOrg": "willbrowne",
|
||||
"signedByOrgName": "Will Browne",
|
||||
"rootUrls": [
|
||||
"http://localhost:3000/"
|
||||
],
|
||||
"plugin": "test",
|
||||
"version": "1.0.0",
|
||||
"time": 1605807018050,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqIEARMKAAYFAl+2q6oACgkQfk0ManCIZudmzwIJAXWz58cd/91rTXszKPnE
|
||||
xbVEvERCbjKTtPBQBNQyqEvV+Ig3MuBSNOVy2SOGrMsdbS6lONgvgt4Cm+iS
|
||||
wV+vYifkAgkBJtg/9DMB7/iX5O0h49CtSltcpfBFXlGqIeOwRac/yENzRzAA
|
||||
khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
|
||||
=rLIE
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Will Browne",
|
||||
"url": "https://willbrowne.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
-----BEGIN PGP SIGNED MESSAGE-----
|
||||
Hash: SHA512
|
||||
|
||||
{
|
||||
"manifestVersion": "2.0.0",
|
||||
"signatureType": "grafana",
|
||||
"signedByOrg": "grafana",
|
||||
"signedByOrgName": "Grafana Labs",
|
||||
"plugin": "test",
|
||||
"version": "1.0.0",
|
||||
"time": 1605807330546,
|
||||
"keyId": "7e4d0c6a708866e7",
|
||||
"files": {
|
||||
"plugin.json": "2bb467c0bfd6c454551419efe475b8bf8573734e73c7bab52b14842adb62886f"
|
||||
}
|
||||
}
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
Version: OpenPGP.js v4.10.1
|
||||
Comment: https://openpgpjs.org
|
||||
|
||||
wqEEARMKAAYFAl+2rOIACgkQfk0ManCIZudNOwIJAT8FTzwnRFCSLTOaR3F3
|
||||
2Fh96eRbghokXcQG9WqpQAg8ZiVfGXeWWRNtV+nuQ9VOZOTO0BovWLuMkym2
|
||||
ci8ABpWOAgd46LkGn3Dd8XVnGmLI6UPqHAXflItOrCMRiGcYJn5PxP1aCz8h
|
||||
D0JoNI9TIKrhMtM4voU3Qhf3mIOTHueuDNS48w==
|
||||
=mu2j
|
||||
-----END PGP SIGNATURE-----
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "datasource",
|
||||
"name": "Test",
|
||||
"id": "test",
|
||||
"backend": true,
|
||||
"executable": "test",
|
||||
"state": "alpha",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": {
|
||||
"name": "Will Browne",
|
||||
"url": "https://willbrowne.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/hashicorp/go-version"
|
||||
)
|
||||
|
||||
var (
|
||||
httpClient = http.Client{Timeout: 10 * time.Second}
|
||||
)
|
||||
|
||||
type GrafanaNetPlugin struct {
|
||||
Slug string `json:"slug"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type GithubLatest struct {
|
||||
Stable string `json:"stable"`
|
||||
Testing string `json:"testing"`
|
||||
}
|
||||
|
||||
func getAllExternalPluginSlugs() string {
|
||||
var result []string
|
||||
for _, plug := range Plugins {
|
||||
if plug.IsCorePlugin {
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, plug.Id)
|
||||
}
|
||||
|
||||
return strings.Join(result, ",")
|
||||
}
|
||||
|
||||
func (pm *PluginManager) checkForUpdates() {
|
||||
if !pm.Cfg.CheckForUpdates {
|
||||
return
|
||||
}
|
||||
|
||||
pm.log.Debug("Checking for updates")
|
||||
|
||||
pluginSlugs := getAllExternalPluginSlugs()
|
||||
resp, err := httpClient.Get("https://grafana.com/api/plugins/versioncheck?slugIn=" + pluginSlugs + "&grafanaVersion=" + setting.BuildVersion)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to get plugins repo from grafana.com, %v", err.Error())
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Warn("Failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Tracef("Update check failed, reading response from grafana.com, %v", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
gNetPlugins := []GrafanaNetPlugin{}
|
||||
err = json.Unmarshal(body, &gNetPlugins)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to unmarshal plugin repo, reading response from grafana.com, %v", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, plug := range Plugins {
|
||||
for _, gplug := range gNetPlugins {
|
||||
if gplug.Slug == plug.Id {
|
||||
plug.GrafanaNetVersion = gplug.Version
|
||||
|
||||
plugVersion, err1 := version.NewVersion(plug.Info.Version)
|
||||
gplugVersion, err2 := version.NewVersion(gplug.Version)
|
||||
|
||||
if err1 != nil || err2 != nil {
|
||||
plug.GrafanaNetHasUpdate = plug.Info.Version != plug.GrafanaNetVersion
|
||||
} else {
|
||||
plug.GrafanaNetHasUpdate = plugVersion.LessThan(gplugVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp2, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/master/latest.json")
|
||||
if err != nil {
|
||||
log.Tracef("Failed to get latest.json repo from github.com: %v", err.Error())
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := resp2.Body.Close(); err != nil {
|
||||
pm.log.Warn("Failed to close response body", "err", err)
|
||||
}
|
||||
}()
|
||||
body, err = ioutil.ReadAll(resp2.Body)
|
||||
if err != nil {
|
||||
log.Tracef("Update check failed, reading response from github.com, %v", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var githubLatest GithubLatest
|
||||
err = json.Unmarshal(body, &githubLatest)
|
||||
if err != nil {
|
||||
log.Tracef("Failed to unmarshal github.com latest, reading response from github.com: %v", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(setting.BuildVersion, "-") {
|
||||
pm.GrafanaLatestVersion = githubLatest.Testing
|
||||
pm.GrafanaHasUpdate = !strings.HasPrefix(setting.BuildVersion, githubLatest.Testing)
|
||||
} else {
|
||||
pm.GrafanaLatestVersion = githubLatest.Stable
|
||||
pm.GrafanaHasUpdate = githubLatest.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user