diff --git a/e2e/start-server b/e2e/start-server index 059085268f7..57a79b8645f 100755 --- a/e2e/start-server +++ b/e2e/start-server @@ -49,7 +49,7 @@ cp -r devenv $RUNDIR echo -e "Starting Grafana Server port $PORT" $RUNDIR/bin/grafana-server \ - --homepath=$RUNDIR \ + --homepath=$HOME_PATH \ --pidfile=$RUNDIR/pid \ cfg:server.http_port=$PORT \ cfg:server.router_logging=1 \ diff --git a/e2e/variables b/e2e/variables index 8a76f6134d3..ea64f7d3673 100644 --- a/e2e/variables +++ b/e2e/variables @@ -2,6 +2,7 @@ DEFAULT_RUNDIR=e2e/tmp RUNDIR=${RUNDIR:-$DEFAULT_RUNDIR} +HOME_PATH=$PWD/$DEFAULT_RUNDIR PIDFILE=$RUNDIR/pid DEFAULT_PACKAGE_FILE=dist/grafana-*linux-amd64.tar.gz PROV_DIR=$RUNDIR/conf/provisioning diff --git a/packages/grafana-ui/src/components/Icon/iconBundle.ts b/packages/grafana-ui/src/components/Icon/iconBundle.ts index 4bbe54eabb9..73da56a6b71 100644 --- a/packages/grafana-ui/src/components/Icon/iconBundle.ts +++ b/packages/grafana-ui/src/components/Icon/iconBundle.ts @@ -141,6 +141,7 @@ import u1132 from '!!raw-loader!../../../../../public/img/icons/mono/heart.svg'; import u1133 from '!!raw-loader!../../../../../public/img/icons/mono/heart-break.svg'; import u1134 from '!!raw-loader!../../../../../public/img/icons/mono/panel-add.svg'; import u1135 from '!!raw-loader!../../../../../public/img/icons/mono/library-panel.svg'; +import u1136 from '!!raw-loader!../../../../../public/img/icons/unicons/capture.svg'; function cacheItem(content: string, path: string) { cacheStore[iconRoot + path] = { content, status: 'loaded', queue: [] }; @@ -291,4 +292,5 @@ export function initIconCache() { cacheItem(u1133, 'mono/heart-break.svg'); cacheItem(u1134, 'mono/panel-add.svg'); cacheItem(u1135, 'mono/library-panel.svg'); + cacheItem(u1136, 'unicons/capture.svg'); } diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index c466dbf141d..86bba8a126f 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -33,6 +33,7 @@ export const getAvailableIcons = () => 'calculator-alt', 'calendar-alt', 'camera', + 'capture', 'channel-add', 'chart-line', 'check', diff --git a/pkg/api/api.go b/pkg/api/api.go index 1106197aae1..774c92b899d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -502,7 +502,7 @@ func (hs *HTTPServer) registerRoutes() { r.Delete("/api/snapshots/:key", reqEditorRole, routing.Wrap(DeleteDashboardSnapshot)) // Frontend logs - sourceMapStore := frontendlogging.NewSourceMapStore(hs.Cfg, hs.PluginManager, frontendlogging.ReadSourceMapFromFS) + sourceMapStore := frontendlogging.NewSourceMapStore(hs.Cfg, hs.pluginStaticRouteResolver, frontendlogging.ReadSourceMapFromFS) r.Post("/log", middleware.RateLimit(hs.Cfg.Sentry.EndpointRPS, hs.Cfg.Sentry.EndpointBurst, time.Now), bind(frontendlogging.FrontendSentryEvent{}), routing.Wrap(NewFrontendLogMessageHandler(sourceMapStore))) } diff --git a/pkg/api/app_routes.go b/pkg/api/app_routes.go index 8a226334cb6..5c51bd4d38e 100644 --- a/pkg/api/app_routes.go +++ b/pkg/api/app_routes.go @@ -32,9 +32,9 @@ func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) { TLSHandshakeTimeout: 10 * time.Second, } - for _, plugin := range hs.PluginManager.Apps() { + for _, plugin := range hs.pluginStore.Plugins(plugins.App) { for _, route := range plugin.Routes { - url := util.JoinURLFragments("/api/plugin-proxy/"+plugin.Id, route.Path) + url := util.JoinURLFragments("/api/plugin-proxy/"+plugin.ID, route.Path) handlers := make([]web.Handler, 0) handlers = append(handlers, middleware.Auth(&middleware.AuthOptions{ ReqSignedIn: true, @@ -47,7 +47,7 @@ func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) { handlers = append(handlers, middleware.RoleAuth(models.ROLE_EDITOR, models.ROLE_ADMIN)) } } - handlers = append(handlers, AppPluginRoute(route, plugin.Id, hs)) + handlers = append(handlers, AppPluginRoute(route, plugin.ID, hs)) for _, method := range strings.Split(route.Method, ",") { r.Handle(strings.TrimSpace(method), url, handlers) } @@ -56,7 +56,7 @@ func (hs *HTTPServer) initAppPluginRoutes(r *web.Mux) { } } -func AppPluginRoute(route *plugins.AppPluginRoute, appID string, hs *HTTPServer) web.Handler { +func AppPluginRoute(route *plugins.Route, appID string, hs *HTTPServer) web.Handler { return func(c *models.ReqContext) { path := web.Params(c.Req)["*"] diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index d973fb852c4..b3d1fa9429f 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -410,7 +410,7 @@ func (hs *HTTPServer) dashboardSaveErrorToApiResponse(err error) response.Respon if ok := errors.As(err, &pluginErr); ok { message := fmt.Sprintf("The dashboard belongs to plugin %s.", pluginErr.PluginId) // look up plugin name - if pluginDef := hs.PluginManager.GetPlugin(pluginErr.PluginId); pluginDef != nil { + if pluginDef := hs.pluginStore.Plugin(pluginErr.PluginId); pluginDef != nil { message = fmt.Sprintf("The dashboard belongs to plugin %s.", pluginDef.Name) } return response.JSON(412, util.DynMap{"status": "plugin-dashboard", "message": message}) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 4751c272add..724158a61b5 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -40,7 +40,7 @@ func TestGetHomeDashboard(t *testing.T) { hs := &HTTPServer{ Cfg: cfg, Bus: bus.New(), - PluginManager: &fakePluginManager{}, + pluginStore: &fakePluginStore{}, } hs.Bus.AddHandlerCtx(func(_ context.Context, query *models.GetPreferencesWithDefaultsQuery) error { query.Result = &models.Preferences{ @@ -1115,7 +1115,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s QuotaService: "a.QuotaService{ Cfg: cfg, }, - PluginManager: &fakePluginManager{}, + pluginStore: &fakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, } diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index b0b488d91b0..2ba2d363857 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -49,7 +49,7 @@ func (hs *HTTPServer) GetDataSources(c *models.ReqContext) response.Response { ReadOnly: ds.ReadOnly, } - if plugin := hs.PluginManager.GetDataSource(ds.Type); plugin != nil { + if plugin := hs.pluginStore.Plugin(ds.Type); plugin != nil { dsItem.TypeLogoUrl = plugin.Info.Logos.Small dsItem.TypeName = plugin.Name } else { @@ -379,8 +379,7 @@ func (hs *HTTPServer) CallDatasourceResource(c *models.ReqContext) { return } - // find plugin - plugin := hs.PluginManager.GetDataSource(ds.Type) + plugin := hs.pluginStore.Plugin(ds.Type) if plugin == nil { c.JsonApiErr(500, "Unable to find datasource plugin", err) return @@ -394,10 +393,10 @@ func (hs *HTTPServer) CallDatasourceResource(c *models.ReqContext) { pCtx := backend.PluginContext{ User: adapters.BackendUserFromSignedInUser(c.SignedInUser), OrgID: c.OrgId, - PluginID: plugin.Id, + PluginID: plugin.ID, DataSourceInstanceSettings: dsInstanceSettings, } - hs.BackendPluginManager.CallResource(pCtx, c, web.Params(c.Req)["*"]) + hs.pluginClient.CallResource(pCtx, c, web.Params(c.Req)["*"]) } func convertModelToDtos(ds *models.DataSource) dtos.DataSource { @@ -445,7 +444,7 @@ func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) response.Respo return response.Error(500, "Unable to load datasource metadata", err) } - plugin := hs.PluginManager.GetDataSource(ds.Type) + plugin := hs.pluginStore.Plugin(ds.Type) if plugin == nil { return response.Error(500, "Unable to find datasource plugin", err) } @@ -454,14 +453,16 @@ func (hs *HTTPServer) CheckDatasourceHealth(c *models.ReqContext) response.Respo if err != nil { return response.Error(500, "Unable to get datasource model", err) } - pCtx := backend.PluginContext{ - User: adapters.BackendUserFromSignedInUser(c.SignedInUser), - OrgID: c.OrgId, - PluginID: plugin.Id, - DataSourceInstanceSettings: dsInstanceSettings, + req := &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + User: adapters.BackendUserFromSignedInUser(c.SignedInUser), + OrgID: c.OrgId, + PluginID: plugin.ID, + DataSourceInstanceSettings: dsInstanceSettings, + }, } - resp, err := hs.BackendPluginManager.CheckHealth(c.Req.Context(), pCtx) + resp, err := hs.pluginClient.CheckHealth(c.Req.Context(), req) if err != nil { return translatePluginRequestErrorToAPIError(err) } diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index 5103d3b5fa2..ff5637b022f 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -42,9 +42,9 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { // handler func being tested hs := &HTTPServer{ - Bus: bus.GetBus(), - Cfg: setting.NewCfg(), - PluginManager: &fakePluginManager{}, + Bus: bus.GetBus(), + Cfg: setting.NewCfg(), + pluginStore: &fakePluginStore{}, } sc.handlerFunc = hs.GetDataSources sc.fakeReq("GET", "/api/datasources").exec() @@ -63,9 +63,9 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { "/api/datasources/name/12345", func(sc *scenarioContext) { // handler func being tested hs := &HTTPServer{ - Bus: bus.GetBus(), - Cfg: setting.NewCfg(), - PluginManager: &fakePluginManager{}, + Bus: bus.GetBus(), + Cfg: setting.NewCfg(), + pluginStore: &fakePluginStore{}, } sc.handlerFunc = hs.DeleteDataSourceByName sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 835e2d22ebb..074c8541a41 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -6,42 +6,42 @@ import ( ) type PluginSetting struct { - Name string `json:"name"` - Type string `json:"type"` - Id string `json:"id"` - Enabled bool `json:"enabled"` - Pinned bool `json:"pinned"` - Module string `json:"module"` - BaseUrl string `json:"baseUrl"` - Info *plugins.PluginInfo `json:"info"` - Includes []*plugins.PluginInclude `json:"includes"` - Dependencies *plugins.PluginDependencies `json:"dependencies"` - JsonData map[string]interface{} `json:"jsonData"` - DefaultNavUrl string `json:"defaultNavUrl"` + Name string `json:"name"` + Type string `json:"type"` + Id string `json:"id"` + Enabled bool `json:"enabled"` + Pinned bool `json:"pinned"` + Module string `json:"module"` + BaseUrl string `json:"baseUrl"` + Info *plugins.Info `json:"info"` + Includes []*plugins.Includes `json:"includes"` + Dependencies *plugins.Dependencies `json:"dependencies"` + JsonData map[string]interface{} `json:"jsonData"` + DefaultNavUrl string `json:"defaultNavUrl"` - LatestVersion string `json:"latestVersion"` - HasUpdate bool `json:"hasUpdate"` - State plugins.PluginState `json:"state"` - Signature plugins.PluginSignatureStatus `json:"signature"` - SignatureType plugins.PluginSignatureType `json:"signatureType"` - SignatureOrg string `json:"signatureOrg"` + LatestVersion string `json:"latestVersion"` + HasUpdate bool `json:"hasUpdate"` + State plugins.ReleaseState `json:"state"` + Signature plugins.SignatureStatus `json:"signature"` + SignatureType plugins.SignatureType `json:"signatureType"` + SignatureOrg string `json:"signatureOrg"` } type PluginListItem struct { - Name string `json:"name"` - Type string `json:"type"` - Id string `json:"id"` - Enabled bool `json:"enabled"` - Pinned bool `json:"pinned"` - Info *plugins.PluginInfo `json:"info"` - LatestVersion string `json:"latestVersion"` - HasUpdate bool `json:"hasUpdate"` - DefaultNavUrl string `json:"defaultNavUrl"` - Category string `json:"category"` - State plugins.PluginState `json:"state"` - Signature plugins.PluginSignatureStatus `json:"signature"` - SignatureType plugins.PluginSignatureType `json:"signatureType"` - SignatureOrg string `json:"signatureOrg"` + Name string `json:"name"` + Type string `json:"type"` + Id string `json:"id"` + Enabled bool `json:"enabled"` + Pinned bool `json:"pinned"` + Info *plugins.Info `json:"info"` + LatestVersion string `json:"latestVersion"` + HasUpdate bool `json:"hasUpdate"` + DefaultNavUrl string `json:"defaultNavUrl"` + Category string `json:"category"` + State plugins.ReleaseState `json:"state"` + Signature plugins.SignatureStatus `json:"signature"` + SignatureType plugins.SignatureType `json:"signatureType"` + SignatureOrg string `json:"signatureOrg"` } type PluginList []PluginListItem diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 705ec6e3f05..a9f58ad4b80 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -2,24 +2,32 @@ package api import "github.com/grafana/grafana/pkg/plugins" -type fakePluginManager struct { - plugins.Manager - - staticRoutes []*plugins.PluginStaticRoute +type fakePluginStore struct { + plugins.Store } -func (pm *fakePluginManager) GetPlugin(id string) *plugins.PluginBase { +func (ps *fakePluginStore) Plugin(pluginID string) *plugins.Plugin { return nil } -func (pm *fakePluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { +func (ps *fakePluginStore) Plugins(pluginType ...plugins.Type) []*plugins.Plugin { return nil } -func (pm *fakePluginManager) Renderer() *plugins.RendererPlugin { +type fakeRendererManager struct { + plugins.RendererManager +} + +func (ps *fakeRendererManager) Renderer() *plugins.Plugin { return nil } -func (pm *fakePluginManager) StaticRoutes() []*plugins.PluginStaticRoute { - return pm.staticRoutes +type fakePluginStaticRouteResolver struct { + plugins.StaticRouteResolver + + routes []*plugins.StaticRoute +} + +func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute { + return psrr.routes } diff --git a/pkg/api/frontend_logging_test.go b/pkg/api/frontend_logging_test.go index 2dfcee56052..03484f7492a 100644 --- a/pkg/api/frontend_logging_test.go +++ b/pkg/api/frontend_logging_test.go @@ -71,11 +71,11 @@ func logSentryEventScenario(t *testing.T, desc string, event frontendlogging.Fro } // fake plugin route so we will try to find a source map there - pm := fakePluginManager{ - staticRoutes: []*plugins.PluginStaticRoute{ + pm := fakePluginStaticRouteResolver{ + routes: []*plugins.StaticRoute{ { Directory: "/usr/local/telepathic-panel", - PluginId: "telepathic", + PluginID: "telepathic", }, }, } diff --git a/pkg/api/frontendlogging/source_maps.go b/pkg/api/frontendlogging/source_maps.go index 5f8b3a5ac63..e52f5000a9c 100644 --- a/pkg/api/frontendlogging/source_maps.go +++ b/pkg/api/frontendlogging/source_maps.go @@ -47,14 +47,14 @@ type SourceMapStore struct { cache map[string]*sourceMap cfg *setting.Cfg readSourceMap ReadSourceMapFn - pluginManager plugins.Manager + routeResolver plugins.StaticRouteResolver } -func NewSourceMapStore(cfg *setting.Cfg, pluginManager plugins.Manager, readSourceMap ReadSourceMapFn) *SourceMapStore { +func NewSourceMapStore(cfg *setting.Cfg, routeResolver plugins.StaticRouteResolver, readSourceMap ReadSourceMapFn) *SourceMapStore { return &SourceMapStore{ cache: make(map[string]*sourceMap), cfg: cfg, - pluginManager: pluginManager, + routeResolver: routeResolver, readSourceMap: readSourceMap, } } @@ -83,13 +83,13 @@ func (store *SourceMapStore) guessSourceMapLocation(sourceURL string) (*sourceMa } // if source comes from a plugin, look in plugin dir } else if strings.HasPrefix(u.Path, "/public/plugins/") { - for _, route := range store.pluginManager.StaticRoutes() { - pluginPrefix := filepath.Join("/public/plugins/", route.PluginId) + for _, route := range store.routeResolver.Routes() { + pluginPrefix := filepath.Join("/public/plugins/", route.PluginID) if strings.HasPrefix(u.Path, pluginPrefix) { return &sourceMapLocation{ dir: route.Directory, path: u.Path[len(pluginPrefix):] + ".map", - pluginID: route.PluginId, + pluginID: route.PluginID, }, nil } } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index f60b1156d65..d4449139649 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana/pkg/util" ) -func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins *plugins.EnabledPlugins) (map[string]interface{}, error) { +func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins EnabledPlugins) (map[string]interface{}, error) { orgDataSources := make([]*models.DataSource, 0) if c.OrgId != 0 { @@ -61,12 +61,19 @@ func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins *plu "access": ds.Access, } - meta, exists := enabledPlugins.DataSources[ds.Type] + meta, exists := enabledPlugins.Get(plugins.DataSource, ds.Type) if !exists { log.Error("Could not find plugin definition for data source", "datasource_type", ds.Type) continue } - dsMap["meta"] = meta + dsMap["preload"] = meta.Preload + dsMap["module"] = meta.Module + dsMap["meta"] = &plugins.PluginMetaDTO{ + JSONData: meta.JSONData, + Signature: meta.Signature, + Module: meta.Module, + BaseURL: meta.BaseURL, + } jsonData := ds.JsonData if jsonData == nil { @@ -113,12 +120,17 @@ func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins *plu // add data sources that are built in (meaning they are not added via data sources page, nor have any entry in // the datasource table) - for _, ds := range hs.PluginManager.DataSources() { + for _, ds := range hs.pluginStore.Plugins(plugins.DataSource) { if ds.BuiltIn { info := map[string]interface{}{ "type": ds.Type, "name": ds.Name, - "meta": hs.PluginManager.GetDataSource(ds.Id), + "meta": &plugins.PluginMetaDTO{ + JSONData: ds.JSONData, + Signature: ds.Signature, + Module: ds.Module, + BaseURL: ds.BaseURL, + }, } if ds.Name == grafanads.DatasourceName { info["id"] = grafanads.DatasourceID @@ -133,13 +145,13 @@ func (hs *HTTPServer) getFSDataSources(c *models.ReqContext, enabledPlugins *plu // getFrontendSettingsMap returns a json object with all the settings needed for front end initialisation. func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]interface{}, error) { - enabledPlugins, err := hs.PluginManager.GetEnabledPlugins(c.OrgId) + enabledPlugins, err := hs.enabledPlugins(c.OrgId) if err != nil { return nil, err } pluginsToPreload := []string{} - for _, app := range enabledPlugins.Apps { + for _, app := range enabledPlugins[plugins.App] { if app.Preload { pluginsToPreload = append(pluginsToPreload, app.Module) } @@ -157,15 +169,15 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i defaultDS = n } - meta := dsM["meta"].(*plugins.DataSourcePlugin) - if meta.Preload { - pluginsToPreload = append(pluginsToPreload, meta.Module) + module, _ := dsM["module"].(string) + if preload, _ := dsM["preload"].(bool); preload && module != "" { + pluginsToPreload = append(pluginsToPreload, module) } } panels := map[string]interface{}{} - for _, panel := range enabledPlugins.Panels { - if panel.State == plugins.PluginStateAlpha && !hs.Cfg.PluginsEnableAlpha { + for _, panel := range enabledPlugins[plugins.Panel] { + if panel.State == plugins.AlphaRelease && !hs.Cfg.PluginsEnableAlpha { continue } @@ -173,14 +185,14 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i pluginsToPreload = append(pluginsToPreload, panel.Module) } - panels[panel.Id] = map[string]interface{}{ + panels[panel.ID] = map[string]interface{}{ + "id": panel.ID, "module": panel.Module, - "baseUrl": panel.BaseUrl, + "baseUrl": panel.BaseURL, "name": panel.Name, - "id": panel.Id, "info": panel.Info, "hideFromList": panel.HideFromList, - "sort": getPanelSort(panel.Id), + "sort": getPanelSort(panel.ID), "skipDataQuery": panel.SkipDataQuery, "state": panel.State, "signature": panel.Signature, @@ -241,8 +253,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "commit": commit, "buildstamp": buildstamp, "edition": hs.License.Edition(), - "latestVersion": hs.PluginManager.GrafanaLatestVersion(), - "hasUpdate": hs.PluginManager.GrafanaHasUpdate(), + "latestVersion": hs.updateChecker.LatestGrafanaVersion(), + "hasUpdate": hs.updateChecker.GrafanaUpdateAvailable(), "env": setting.Env, "isEnterprise": hs.License.HasValidLicense(), }, @@ -335,3 +347,96 @@ func (hs *HTTPServer) GetFrontendSettings(c *models.ReqContext) { c.JSON(200, settings) } + +// EnabledPlugins represents a mapping from plugin types (panel, data source, etc.) to plugin IDs to plugins +// For example ["panel"] -> ["piechart"] -> {pie chart plugin instance} +type EnabledPlugins map[plugins.Type]map[string]*plugins.Plugin + +func (ep EnabledPlugins) Get(pluginType plugins.Type, pluginID string) (*plugins.Plugin, bool) { + if _, exists := ep[pluginType][pluginID]; exists { + return ep[pluginType][pluginID], true + } + + return nil, false +} + +func (hs *HTTPServer) enabledPlugins(orgID int64) (EnabledPlugins, error) { + ep := make(EnabledPlugins) + + pluginSettingMap, err := hs.pluginSettings(orgID) + if err != nil { + return ep, err + } + + apps := make(map[string]*plugins.Plugin) + for _, app := range hs.pluginStore.Plugins(plugins.App) { + if b, exists := pluginSettingMap[app.ID]; exists { + app.Pinned = b.Pinned + apps[app.ID] = app + } + } + ep[plugins.App] = apps + + dataSources := make(map[string]*plugins.Plugin) + for _, ds := range hs.pluginStore.Plugins(plugins.DataSource) { + if _, exists := pluginSettingMap[ds.ID]; exists { + dataSources[ds.ID] = ds + } + } + ep[plugins.DataSource] = dataSources + + panels := make(map[string]*plugins.Plugin) + for _, p := range hs.pluginStore.Plugins(plugins.Panel) { + if _, exists := pluginSettingMap[p.ID]; exists { + panels[p.ID] = p + } + } + ep[plugins.Panel] = panels + + return ep, nil +} + +func (hs *HTTPServer) pluginSettings(orgID int64) (map[string]*models.PluginSettingInfoDTO, error) { + pluginSettings, err := hs.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 hs.pluginStore.Plugins() { + // ignore entries that already exist + if _, ok := pluginMap[pluginDef.ID]; ok { + continue + } + + // enabled by default + opt := &models.PluginSettingInfoDTO{ + PluginId: pluginDef.ID, + OrgId: orgID, + Enabled: true, + } + + // apps are disabled by default unless autoEnabled: true + if p := hs.pluginStore.Plugin(pluginDef.ID); p != nil && p.IsApp() { + opt.Enabled = p.AutoEnabled + opt.Pinned = p.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 +} diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 9ae2b268957..c2ffca308a5 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -8,11 +8,11 @@ import ( "testing" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/plugins/manager" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/assert" @@ -35,20 +35,18 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg) (*web.Mux, *HTTPServer } sqlStore := sqlstore.InitTestDB(t) - pm := &manager.PluginManager{Cfg: cfg, SQLStore: sqlStore} - - r := &rendering.RenderingService{ - Cfg: cfg, - PluginManager: pm, - } hs := &HTTPServer{ - Cfg: cfg, - Bus: bus.GetBus(), - License: &licensing.OSSLicensingService{Cfg: cfg}, - RenderService: r, + Cfg: cfg, + Bus: bus.GetBus(), + License: &licensing.OSSLicensingService{Cfg: cfg}, + RenderService: &rendering.RenderingService{ + Cfg: cfg, + RendererPluginManager: &fakeRendererManager{}, + }, SQLStore: sqlStore, - PluginManager: pm, + pluginStore: &fakePluginStore{}, + updateChecker: &updatechecker.Service{}, AccessControl: accesscontrolmock.New().WithDisabled(), } @@ -60,7 +58,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg) (*web.Mux, *HTTPServer return m, hs } -func TestHTTPServer_GetFrontendSettings_hideVersionAnonyomus(t *testing.T) { +func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { type buildInfo struct { Version string `json:"version"` Commit string `json:"commit"` diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 2d922991b7e..c60c37d4995 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -13,8 +13,6 @@ import ( "strings" "sync" - "github.com/grafana/grafana/pkg/services/searchusers" - "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" "github.com/grafana/grafana/pkg/bus" @@ -28,8 +26,6 @@ import ( "github.com/grafana/grafana/pkg/middleware" "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/manager" "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/alerting" @@ -52,8 +48,10 @@ import ( "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/schemaloader" "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/util/errutil" @@ -69,48 +67,52 @@ type HTTPServer struct { httpSrv *http.Server middlewares []web.Handler - PluginContextProvider *plugincontext.Provider - RouteRegister routing.RouteRegister - Bus bus.Bus - RenderService rendering.Service - Cfg *setting.Cfg - SettingsProvider setting.Provider - HooksService *hooks.HooksService - CacheService *localcache.CacheService - DataSourceCache datasources.CacheService - AuthTokenService models.UserTokenService - QuotaService *quota.QuotaService - RemoteCacheService *remotecache.RemoteCache - ProvisioningService provisioning.ProvisioningService - Login login.Service - License models.Licensing - AccessControl accesscontrol.AccessControl - BackendPluginManager backendplugin.Manager - DataProxy *datasourceproxy.DataSourceProxyService - PluginRequestValidator models.PluginRequestValidator - PluginManager plugins.Manager - SearchService *search.SearchService - ShortURLService shorturls.Service - Live *live.GrafanaLive - LivePushGateway *pushhttp.Gateway - ContextHandler *contexthandler.ContextHandler - SQLStore *sqlstore.SQLStore - DataService *tsdb.Service - AlertEngine *alerting.AlertEngine - LoadSchemaService *schemaloader.SchemaLoaderService - AlertNG *ngalert.AlertNG - LibraryPanelService librarypanels.Service - LibraryElementService libraryelements.Service - notificationService *notifications.NotificationService - SocialService social.Service - OAuthTokenService oauthtoken.OAuthTokenService - Listener net.Listener - EncryptionService encryption.Service - DataSourcesService *datasources.Service - cleanUpService *cleanup.CleanUpService - tracingService *tracing.TracingService - internalMetricsSvc *metrics.InternalMetricsService - searchUsersService searchusers.Service + PluginContextProvider *plugincontext.Provider + RouteRegister routing.RouteRegister + Bus bus.Bus + RenderService rendering.Service + Cfg *setting.Cfg + SettingsProvider setting.Provider + HooksService *hooks.HooksService + CacheService *localcache.CacheService + DataSourceCache datasources.CacheService + AuthTokenService models.UserTokenService + QuotaService *quota.QuotaService + RemoteCacheService *remotecache.RemoteCache + ProvisioningService provisioning.ProvisioningService + Login login.Service + License models.Licensing + AccessControl accesscontrol.AccessControl + DataProxy *datasourceproxy.DataSourceProxyService + PluginRequestValidator models.PluginRequestValidator + pluginClient plugins.Client + pluginStore plugins.Store + pluginDashboardManager plugins.PluginDashboardManager + pluginStaticRouteResolver plugins.StaticRouteResolver + pluginErrorResolver plugins.ErrorResolver + SearchService *search.SearchService + ShortURLService shorturls.Service + Live *live.GrafanaLive + LivePushGateway *pushhttp.Gateway + ContextHandler *contexthandler.ContextHandler + SQLStore *sqlstore.SQLStore + DataService *tsdb.Service + AlertEngine *alerting.AlertEngine + LoadSchemaService *schemaloader.SchemaLoaderService + AlertNG *ngalert.AlertNG + LibraryPanelService librarypanels.Service + LibraryElementService libraryelements.Service + notificationService *notifications.NotificationService + SocialService social.Service + OAuthTokenService oauthtoken.OAuthTokenService + Listener net.Listener + EncryptionService encryption.Service + DataSourcesService *datasources.Service + cleanUpService *cleanup.CleanUpService + tracingService *tracing.TracingService + internalMetricsSvc *metrics.InternalMetricsService + updateChecker *updatechecker.Service + searchUsersService searchusers.Service } type ServerOptions struct { @@ -120,8 +122,10 @@ type ServerOptions struct { func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, bus bus.Bus, renderService rendering.Service, licensing models.Licensing, hooksService *hooks.HooksService, cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, - dataService *tsdb.Service, alertEngine *alerting.AlertEngine, pluginRequestValidator models.PluginRequestValidator, - pluginManager plugins.Manager, backendPM backendplugin.Manager, settingsProvider setting.Provider, + dataService *tsdb.Service, alertEngine *alerting.AlertEngine, + pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver, + pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client, + pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider, dataSourceCache datasources.CacheService, userTokenService models.UserTokenService, cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService, @@ -134,56 +138,60 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi notificationService *notifications.NotificationService, tracingService *tracing.TracingService, internalMetricsSvc *metrics.InternalMetricsService, quotaService *quota.QuotaService, socialService social.Service, oauthTokenService oauthtoken.OAuthTokenService, - encryptionService encryption.Service, searchUsersService searchusers.Service, + encryptionService encryption.Service, updateChecker *updatechecker.Service, searchUsersService searchusers.Service, dataSourcesService *datasources.Service) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() hs := &HTTPServer{ - Cfg: cfg, - RouteRegister: routeRegister, - Bus: bus, - RenderService: renderService, - License: licensing, - HooksService: hooksService, - CacheService: cacheService, - SQLStore: sqlStore, - DataService: dataService, - AlertEngine: alertEngine, - PluginRequestValidator: pluginRequestValidator, - PluginManager: pluginManager, - BackendPluginManager: backendPM, - SettingsProvider: settingsProvider, - DataSourceCache: dataSourceCache, - AuthTokenService: userTokenService, - cleanUpService: cleanUpService, - ShortURLService: shortURLService, - RemoteCacheService: remoteCache, - ProvisioningService: provisioningService, - Login: loginService, - AccessControl: accessControl, - DataProxy: dataSourceProxy, - SearchService: searchService, - Live: live, - LivePushGateway: livePushGateway, - PluginContextProvider: plugCtxProvider, - ContextHandler: contextHandler, - LoadSchemaService: schemaService, - AlertNG: alertNG, - LibraryPanelService: libraryPanelService, - LibraryElementService: libraryElementService, - QuotaService: quotaService, - notificationService: notificationService, - tracingService: tracingService, - internalMetricsSvc: internalMetricsSvc, - log: log.New("http.server"), - web: m, - Listener: opts.Listener, - SocialService: socialService, - OAuthTokenService: oauthTokenService, - EncryptionService: encryptionService, - DataSourcesService: dataSourcesService, - searchUsersService: searchUsersService, + Cfg: cfg, + RouteRegister: routeRegister, + Bus: bus, + RenderService: renderService, + License: licensing, + HooksService: hooksService, + CacheService: cacheService, + SQLStore: sqlStore, + DataService: dataService, + AlertEngine: alertEngine, + PluginRequestValidator: pluginRequestValidator, + pluginClient: pluginClient, + pluginStore: pluginStore, + pluginStaticRouteResolver: pluginStaticRouteResolver, + pluginDashboardManager: pluginDashboardManager, + pluginErrorResolver: pluginErrorResolver, + updateChecker: updateChecker, + SettingsProvider: settingsProvider, + DataSourceCache: dataSourceCache, + AuthTokenService: userTokenService, + cleanUpService: cleanUpService, + ShortURLService: shortURLService, + RemoteCacheService: remoteCache, + ProvisioningService: provisioningService, + Login: loginService, + AccessControl: accessControl, + DataProxy: dataSourceProxy, + SearchService: searchService, + Live: live, + LivePushGateway: livePushGateway, + PluginContextProvider: plugCtxProvider, + ContextHandler: contextHandler, + LoadSchemaService: schemaService, + AlertNG: alertNG, + LibraryPanelService: libraryPanelService, + LibraryElementService: libraryElementService, + QuotaService: quotaService, + notificationService: notificationService, + tracingService: tracingService, + internalMetricsSvc: internalMetricsSvc, + log: log.New("http.server"), + web: m, + Listener: opts.Listener, + SocialService: socialService, + OAuthTokenService: oauthTokenService, + EncryptionService: encryptionService, + DataSourcesService: dataSourcesService, + searchUsersService: searchUsersService, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/api/index.go b/pkg/api/index.go index 9d763d10a7b..1cebaf4f35e 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/api/navlinks" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/setting" ) @@ -65,21 +66,21 @@ func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink { } func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) { - enabledPlugins, err := hs.PluginManager.GetEnabledPlugins(c.OrgId) + enabledPlugins, err := hs.enabledPlugins(c.OrgId) if err != nil { return nil, err } appLinks := []*dtos.NavLink{} - for _, plugin := range enabledPlugins.Apps { + for _, plugin := range enabledPlugins[plugins.App] { if !plugin.Pinned { continue } appLink := &dtos.NavLink{ Text: plugin.Name, - Id: "plugin-page-" + plugin.Id, - Url: plugin.DefaultNavUrl, + Id: "plugin-page-" + plugin.ID, + Url: plugin.DefaultNavURL, Img: plugin.Info.Logos.Small, SortWeight: dtos.WeightPlugin, } @@ -101,7 +102,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } } else { link = &dtos.NavLink{ - Url: hs.Cfg.AppSubURL + "/plugins/" + plugin.Id + "/page/" + include.Slug, + Url: hs.Cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug, Text: include.Name, } } @@ -504,8 +505,8 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat GoogleTagManagerId: setting.GoogleTagManagerId, BuildVersion: setting.BuildVersion, BuildCommit: setting.BuildCommit, - NewGrafanaVersion: hs.PluginManager.GrafanaLatestVersion(), - NewGrafanaVersionExists: hs.PluginManager.GrafanaHasUpdate(), + NewGrafanaVersion: hs.updateChecker.LatestGrafanaVersion(), + NewGrafanaVersionExists: hs.updateChecker.GrafanaUpdateAvailable(), AppName: setting.ApplicationName, AppNameBodyClass: getAppNameBodyClass(hs.License.HasValidLicense()), FavIcon: "public/img/fav32.png", diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index d99940ad0d1..a86567e6033 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" + "time" "github.com/grafana/grafana/pkg/tsdb/grafanads" @@ -12,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/adapters" ) // QueryMetricsV2 returns query metrics. @@ -82,19 +84,17 @@ func (hs *HTTPServer) QueryMetricsV2(c *models.ReqContext, reqDTO dtos.MetricReq return response.Error(http.StatusForbidden, "Access denied", err) } - resp, err := hs.DataService.HandleRequest(c.Req.Context(), ds, request) + req, err := hs.createRequest(ds, request) + if err != nil { + return response.Error(http.StatusBadRequest, "Request formation error", err) + } + + resp, err := hs.pluginClient.QueryData(c.Req.Context(), req) if err != nil { return response.Error(http.StatusInternalServerError, "Metric request error", err) } - // This is insanity... but ¯\_(ツ)_/¯, the current query path looks like: - // encodeJson( decodeBase64( encodeBase64( decodeArrow( encodeArrow(frame)) ) ) - // this will soon change to a more direct route - qdr, err := resp.ToBackendDataResponse() - if err != nil { - return response.Error(http.StatusInternalServerError, "error converting results", err) - } - return toMacronResponse(qdr) + return toMacronResponse(resp) } func toMacronResponse(qdr *backend.QueryDataResponse) response.Response { @@ -222,3 +222,42 @@ func (hs *HTTPServer) QueryMetrics(c *models.ReqContext, reqDto dtos.MetricReque return response.JSON(statusCode, &resp) } + +// nolint:staticcheck // plugins.DataQueryResponse deprecated +func (hs *HTTPServer) createRequest(ds *models.DataSource, query plugins.DataQuery) (*backend.QueryDataRequest, error) { + instanceSettings, err := adapters.ModelToInstanceSettings(ds, hs.decryptSecureJsonDataFn()) + if err != nil { + return nil, err + } + + req := &backend.QueryDataRequest{ + PluginContext: backend.PluginContext{ + OrgID: ds.OrgId, + PluginID: ds.Type, + User: adapters.BackendUserFromSignedInUser(query.User), + DataSourceInstanceSettings: instanceSettings, + }, + Queries: []backend.DataQuery{}, + Headers: query.Headers, + } + + for _, q := range query.Queries { + modelJSON, err := q.Model.MarshalJSON() + if err != nil { + return nil, err + } + req.Queries = append(req.Queries, backend.DataQuery{ + RefID: q.RefID, + Interval: time.Duration(q.IntervalMS) * time.Millisecond, + MaxDataPoints: q.MaxDataPoints, + TimeRange: backend.TimeRange{ + From: query.TimeRange.GetFromAsTimeUTC(), + To: query.TimeRange.GetToAsTimeUTC(), + }, + QueryType: q.QueryType, + JSON: modelJSON, + }) + } + + return req, nil +} diff --git a/pkg/api/pluginproxy/ds_auth_provider.go b/pkg/api/pluginproxy/ds_auth_provider.go index ff1dfce2ade..4a70b9db8d7 100644 --- a/pkg/api/pluginproxy/ds_auth_provider.go +++ b/pkg/api/pluginproxy/ds_auth_provider.go @@ -21,7 +21,7 @@ type DSInfo struct { } // ApplyRoute should use the plugin route data to set auth headers and custom headers. -func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route *plugins.AppPluginRoute, +func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route *plugins.Route, ds DSInfo, cfg *setting.Cfg) { proxyPath = strings.TrimPrefix(proxyPath, route.Path) @@ -76,7 +76,7 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route } } -func getTokenProvider(ctx context.Context, cfg *setting.Cfg, ds DSInfo, pluginRoute *plugins.AppPluginRoute, +func getTokenProvider(ctx context.Context, cfg *setting.Cfg, ds DSInfo, pluginRoute *plugins.Route, data templateData) (accessTokenProvider, error) { authType := pluginRoute.AuthType @@ -133,7 +133,7 @@ func getTokenProvider(ctx context.Context, cfg *setting.Cfg, ds DSInfo, pluginRo } } -func interpolateAuthParams(tokenAuth *plugins.JwtTokenAuth, data templateData) (*plugins.JwtTokenAuth, error) { +func interpolateAuthParams(tokenAuth *plugins.JWTTokenAuth, data templateData) (*plugins.JWTTokenAuth, error) { if tokenAuth == nil { // Nothing to interpolate return nil, nil @@ -153,7 +153,7 @@ func interpolateAuthParams(tokenAuth *plugins.JwtTokenAuth, data templateData) ( interpolatedParams[key] = interpolatedParam } - return &plugins.JwtTokenAuth{ + return &plugins.JWTTokenAuth{ Url: interpolatedUrl, Scopes: tokenAuth.Scopes, Params: interpolatedParams, diff --git a/pkg/api/pluginproxy/ds_auth_provider_test.go b/pkg/api/pluginproxy/ds_auth_provider_test.go index 1aee6bf5da6..84b89671e91 100644 --- a/pkg/api/pluginproxy/ds_auth_provider_test.go +++ b/pkg/api/pluginproxy/ds_auth_provider_test.go @@ -9,7 +9,7 @@ import ( ) func TestApplyRoute_interpolateAuthParams(t *testing.T) { - tokenAuth := &plugins.JwtTokenAuth{ + tokenAuth := &plugins.JWTTokenAuth{ Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token", Scopes: []string{ "https://www.testapi.com/auth/Read.All", @@ -38,7 +38,7 @@ func TestApplyRoute_interpolateAuthParams(t *testing.T) { SecureJsonData: map[string]string{}, } - t.Run("should interpolate JwtTokenAuth struct using given JsonData", func(t *testing.T) { + t.Run("should interpolate JWTTokenAuth struct using given JsonData", func(t *testing.T) { interpolated, err := interpolateAuthParams(tokenAuth, validData) require.NoError(t, err) require.NotNil(t, interpolated) @@ -54,7 +54,7 @@ func TestApplyRoute_interpolateAuthParams(t *testing.T) { assert.Equal(t, "testkey", interpolated.Params["private_key"]) }) - t.Run("should return Nil if given JwtTokenAuth is Nil", func(t *testing.T) { + t.Run("should return Nil if given JWTTokenAuth is Nil", func(t *testing.T) { interpolated, err := interpolateAuthParams(nil, validData) require.NoError(t, err) require.Nil(t, interpolated) diff --git a/pkg/api/pluginproxy/ds_proxy.go b/pkg/api/pluginproxy/ds_proxy.go index 18551806081..8a994a16645 100644 --- a/pkg/api/pluginproxy/ds_proxy.go +++ b/pkg/api/pluginproxy/ds_proxy.go @@ -36,8 +36,8 @@ type DataSourceProxy struct { ctx *models.ReqContext targetUrl *url.URL proxyPath string - route *plugins.AppPluginRoute - plugin *plugins.DataSourcePlugin + matchedRoute *plugins.Route + pluginRoutes []*plugins.Route cfg *setting.Cfg clientProvider httpclient.Provider oAuthTokenService oauthtoken.OAuthTokenService @@ -73,7 +73,7 @@ func (lw *logWrapper) Write(p []byte) (n int, err error) { } // NewDataSourceProxy creates a new Datasource proxy -func NewDataSourceProxy(ds *models.DataSource, plugin *plugins.DataSourcePlugin, ctx *models.ReqContext, +func NewDataSourceProxy(ds *models.DataSource, pluginRoutes []*plugins.Route, ctx *models.ReqContext, proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider, oAuthTokenService oauthtoken.OAuthTokenService, dsService *datasources.Service) (*DataSourceProxy, error) { targetURL, err := datasource.ValidateURL(ds.Type, ds.Url) @@ -83,7 +83,7 @@ func NewDataSourceProxy(ds *models.DataSource, plugin *plugins.DataSourcePlugin, return &DataSourceProxy{ ds: ds, - plugin: plugin, + pluginRoutes: pluginRoutes, ctx: ctx, proxyPath: proxyPath, targetUrl: targetURL, @@ -257,8 +257,8 @@ func (proxy *DataSourceProxy) director(req *http.Request) { return } - if proxy.route != nil { - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, DSInfo{ + if proxy.matchedRoute != nil { + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, DSInfo{ ID: proxy.ds.Id, Updated: proxy.ds.Updated, JSONData: jsonData, @@ -291,27 +291,25 @@ func (proxy *DataSourceProxy) validateRequest() error { } // found route if there are any - if len(proxy.plugin.Routes) > 0 { - for _, route := range proxy.plugin.Routes { - // method match - if route.Method != "" && route.Method != "*" && route.Method != proxy.ctx.Req.Method { - continue - } - - // route match - if !strings.HasPrefix(proxy.proxyPath, route.Path) { - continue - } - - if route.ReqRole.IsValid() { - if !proxy.ctx.HasUserRole(route.ReqRole) { - return errors.New("plugin proxy route access denied") - } - } - - proxy.route = route - return nil + for _, route := range proxy.pluginRoutes { + // method match + if route.Method != "" && route.Method != "*" && route.Method != proxy.ctx.Req.Method { + continue } + + // route match + if !strings.HasPrefix(proxy.proxyPath, route.Path) { + continue + } + + if route.ReqRole.IsValid() { + if !proxy.ctx.HasUserRole(route.ReqRole) { + return errors.New("plugin proxy route access denied") + } + } + + proxy.matchedRoute = route + return nil } // Trailing validation below this point for routes that were not matched diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 395df074623..72359ffeaeb 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -32,51 +32,49 @@ func TestDataSourceProxy_routeRule(t *testing.T) { httpClientProvider := httpclient.NewProvider() t.Run("Plugin with routes", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{ - Routes: []*plugins.AppPluginRoute{ - { - Path: "api/v4/", - URL: "https://www.google.com", - ReqRole: models.ROLE_EDITOR, - Headers: []plugins.AppPluginRouteHeader{ - {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, - }, + routes := []*plugins.Route{ + { + Path: "api/v4/", + URL: "https://www.google.com", + ReqRole: models.ROLE_EDITOR, + Headers: []plugins.Header{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, - { - Path: "api/admin", - URL: "https://www.google.com", - ReqRole: models.ROLE_ADMIN, - Headers: []plugins.AppPluginRouteHeader{ - {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, - }, + }, + { + Path: "api/admin", + URL: "https://www.google.com", + ReqRole: models.ROLE_ADMIN, + Headers: []plugins.Header{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, - { - Path: "api/anon", - URL: "https://www.google.com", - Headers: []plugins.AppPluginRouteHeader{ - {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, - }, + }, + { + Path: "api/anon", + URL: "https://www.google.com", + Headers: []plugins.Header{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, - { - Path: "api/common", - URL: "{{.JsonData.dynamicUrl}}", - URLParams: []plugins.AppPluginRouteURLParam{ - {Name: "{{.JsonData.queryParam}}", Content: "{{.SecureJsonData.key}}"}, - }, - Headers: []plugins.AppPluginRouteHeader{ - {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, - }, + }, + { + Path: "api/common", + URL: "{{.JsonData.dynamicUrl}}", + URLParams: []plugins.URLParam{ + {Name: "{{.JsonData.queryParam}}", Content: "{{.SecureJsonData.key}}"}, }, - { - Path: "api/restricted", - ReqRole: models.ROLE_ADMIN, - }, - { - Path: "api/body", - URL: "http://www.test.com", - Body: []byte(`{ "url": "{{.JsonData.dynamicUrl}}", "secret": "{{.SecureJsonData.key}}" }`), + Headers: []plugins.Header{ + {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, }, + { + Path: "api/restricted", + ReqRole: models.ROLE_ADMIN, + }, + { + Path: "api/body", + URL: "http://www.test.com", + Body: []byte(`{ "url": "{{.JsonData.dynamicUrl}}", "secret": "{{.SecureJsonData.key}}" }`), + }, } origSecretKey := setting.SecretKey @@ -125,10 +123,11 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path", func(t *testing.T) { ctx, req := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, + &oauthtoken.Service{}, dsService) require.NoError(t, err) - proxy.route = plugin.Routes[0] - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, dsInfo, cfg) + proxy.matchedRoute = routes[0] + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, dsInfo, cfg) assert.Equal(t, "https://www.google.com/some/method", req.URL.String()) assert.Equal(t, "my secret 123", req.Header.Get("x-header")) @@ -137,10 +136,10 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic url", func(t *testing.T) { ctx, req := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/common/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) - proxy.route = plugin.Routes[3] - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, dsInfo, cfg) + proxy.matchedRoute = routes[3] + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, dsInfo, cfg) assert.Equal(t, "https://dynamic.grafana.com/some/method?apiKey=123", req.URL.String()) assert.Equal(t, "my secret 123", req.Header.Get("x-header")) @@ -149,10 +148,10 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path with no url", func(t *testing.T) { ctx, req := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) - proxy.route = plugin.Routes[4] - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, dsInfo, cfg) + proxy.matchedRoute = routes[4] + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, dsInfo, cfg) assert.Equal(t, "http://localhost/asd", req.URL.String()) }) @@ -160,10 +159,10 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("When matching route path and has dynamic body", func(t *testing.T) { ctx, req := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/body", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) - proxy.route = plugin.Routes[5] - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.route, dsInfo, cfg) + proxy.matchedRoute = routes[5] + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, dsInfo, cfg) content, err := ioutil.ReadAll(req.Body) require.NoError(t, err) @@ -174,7 +173,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with valid role", func(t *testing.T) { ctx, _ := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/v4/some/method", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) err = proxy.validateRequest() require.NoError(t, err) @@ -183,7 +182,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { t.Run("plugin route with admin role and user is editor", func(t *testing.T) { ctx, _ := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) err = proxy.validateRequest() require.Error(t, err) @@ -193,7 +192,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) { ctx, _ := setUp() ctx.SignedInUser.OrgRole = models.ROLE_ADMIN dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "api/admin", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) err = proxy.validateRequest() require.NoError(t, err) @@ -202,32 +201,30 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }) t.Run("Plugin with multiple routes for token auth", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{ - Routes: []*plugins.AppPluginRoute{ - { - Path: "pathwithtoken1", - URL: "https://api.nr1.io/some/path", - TokenAuth: &plugins.JwtTokenAuth{ - Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token", - Params: map[string]string{ - "grant_type": "client_credentials", - "client_id": "{{.JsonData.clientId}}", - "client_secret": "{{.SecureJsonData.clientSecret}}", - "resource": "https://api.nr1.io", - }, + routes := []*plugins.Route{ + { + Path: "pathwithtoken1", + URL: "https://api.nr1.io/some/path", + TokenAuth: &plugins.JWTTokenAuth{ + Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token", + Params: map[string]string{ + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://api.nr1.io", }, }, - { - Path: "pathwithtoken2", - URL: "https://api.nr2.io/some/path", - TokenAuth: &plugins.JwtTokenAuth{ - Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token", - Params: map[string]string{ - "grant_type": "client_credentials", - "client_id": "{{.JsonData.clientId}}", - "client_secret": "{{.SecureJsonData.clientSecret}}", - "resource": "https://api.nr2.io", - }, + }, + { + Path: "pathwithtoken2", + URL: "https://api.nr2.io/some/path", + TokenAuth: &plugins.JWTTokenAuth{ + Url: "https://login.server.com/{{.JsonData.tenantId}}/oauth2/token", + Params: map[string]string{ + "grant_type": "client_credentials", + "client_id": "{{.JsonData.clientId}}", + "client_secret": "{{.SecureJsonData.clientSecret}}", + "resource": "https://api.nr2.io", }, }, }, @@ -285,9 +282,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { } dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, plugin.Routes[0], dsInfo, cfg) + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) authorizationHeaderCall1 = req.Header.Get("Authorization") assert.Equal(t, "https://api.nr1.io/some/path", req.URL.String()) @@ -301,9 +298,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { require.NoError(t, err) client = newFakeHTTPClient(t, json2) dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken2", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, plugin.Routes[1], dsInfo, cfg) + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[1], dsInfo, cfg) authorizationHeaderCall2 = req.Header.Get("Authorization") @@ -318,9 +315,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { client = newFakeHTTPClient(t, []byte{}) dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "pathwithtoken1", cfg, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) - ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, plugin.Routes[0], dsInfo, cfg) + ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, routes[0], dsInfo, cfg) authorizationHeaderCall3 := req.Header.Get("Authorization") assert.Equal(t, "https://api.nr1.io/some/path", req.URL.String()) @@ -334,12 +331,12 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }) t.Run("When proxying graphite", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{} + var routes []*plugins.Route ds := &models.DataSource{Url: "htttp://graphite:8080", Type: models.DS_GRAPHITE} ctx := &models.ReqContext{} dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{BuildVersion: "5.3.0"}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) require.NoError(t, err) @@ -353,8 +350,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }) t.Run("When proxying InfluxDB", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{} - ds := &models.DataSource{ Type: models.DS_INFLUXDB_08, Url: "http://influxdb:8083", @@ -364,8 +359,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { } ctx := &models.ReqContext{} + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -376,8 +372,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }) t.Run("When proxying a data source with no keepCookies specified", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{} - json, err := simplejson.NewJson([]byte(`{"keepCookies": []}`)) require.NoError(t, err) @@ -388,8 +382,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { } ctx := &models.ReqContext{} + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) requestURL, err := url.Parse("http://grafana.com/sub") @@ -404,8 +399,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }) t.Run("When proxying a data source with keep cookies specified", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{} - json, err := simplejson.NewJson([]byte(`{"keepCookies": ["JSESSION_ID"]}`)) require.NoError(t, err) @@ -416,8 +409,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { } ctx := &models.ReqContext{} + var pluginRoutes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, pluginRoutes, ctx, "", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) requestURL, err := url.Parse("http://grafana.com/sub") @@ -432,14 +426,14 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }) t.Run("When proxying a custom datasource", func(t *testing.T) { - plugin := &plugins.DataSourcePlugin{} ds := &models.DataSource{ Type: "custom-datasource", Url: "http://host/root/", } ctx := &models.ReqContext{} + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) req.Header.Set("Origin", "grafana.com") @@ -470,7 +464,6 @@ func TestDataSourceProxy_routeRule(t *testing.T) { return nil }) - plugin := &plugins.DataSourcePlugin{} ds := &models.DataSource{ Type: "custom-datasource", Url: "http://host/root/", @@ -494,8 +487,9 @@ func TestDataSourceProxy_routeRule(t *testing.T) { }, oAuthEnabled: true, } + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/to/folder/", &setting.Cfg{}, httpClientProvider, &mockAuthToken, dsService) require.NoError(t, err) req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) require.NoError(t, err) @@ -570,8 +564,6 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { httpClientProvider := httpclient.NewProvider() var writeErr error - plugin := &plugins.DataSourcePlugin{} - type setUpCfg struct { headers map[string]string writeCb func(w http.ResponseWriter, r *http.Request) @@ -621,8 +613,10 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { t.Run("When response header Set-Cookie is not set should remove proxied Set-Cookie header", func(t *testing.T) { ctx, ds := setUp(t) + + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) proxy.HandleRequest() @@ -637,8 +631,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { "Set-Cookie": "important_cookie=important_value", }, }) + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) proxy.HandleRequest() @@ -657,8 +652,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { t.Log("Wrote 401 response") }, }) + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/render", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) proxy.HandleRequest() @@ -680,8 +676,9 @@ func TestDataSourceProxy_requestHandling(t *testing.T) { }) ctx.Req = httptest.NewRequest("GET", "/api/datasources/proxy/1/path/%2Ftest%2Ftest%2F?query=%2Ftest%2Ftest%2F", nil) + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "/path/%2Ftest%2Ftest%2F", &setting.Cfg{}, httpClientProvider, &oauthtoken.Service{}, dsService) require.NoError(t, err) proxy.HandleRequest() @@ -702,9 +699,9 @@ func TestNewDataSourceProxy_InvalidURL(t *testing.T) { Url: "://host/root", } cfg := setting.Cfg{} - plugin := plugins.DataSourcePlugin{} + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - _, err := NewDataSourceProxy(&ds, &plugin, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) + _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) require.Error(t, err) assert.True(t, strings.HasPrefix(err.Error(), `validation of data source URL "://host/root" failed`)) } @@ -719,10 +716,10 @@ func TestNewDataSourceProxy_ProtocolLessURL(t *testing.T) { Url: "127.0.01:5432", } cfg := setting.Cfg{} - plugin := plugins.DataSourcePlugin{} + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - _, err := NewDataSourceProxy(&ds, &plugin, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) + _, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) require.NoError(t, err) } @@ -754,14 +751,14 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { for _, tc := range tcs { t.Run(tc.description, func(t *testing.T) { cfg := setting.Cfg{} - plugin := plugins.DataSourcePlugin{} ds := models.DataSource{ Type: "mssql", Url: tc.url, } + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - p, err := NewDataSourceProxy(&ds, &plugin, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) + p, err := NewDataSourceProxy(&ds, routes, &ctx, "api/method", &cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) if tc.err == nil { require.NoError(t, err) assert.Equal(t, &url.URL{ @@ -778,15 +775,14 @@ func TestNewDataSourceProxy_MSSQL(t *testing.T) { // getDatasourceProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. func getDatasourceProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *setting.Cfg) *http.Request { - plugin := &plugins.DataSourcePlugin{} - ds := &models.DataSource{ Type: "custom", Url: "http://host/root/", } + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(ds, plugin, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) require.NoError(t, err) @@ -903,10 +899,10 @@ func createAuthTest(t *testing.T, dsType string, authType string, authCheck stri } func runDatasourceAuthTest(t *testing.T, test *testCase) { - plugin := &plugins.DataSourcePlugin{} ctx := &models.ReqContext{} + var routes []*plugins.Route dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(test.datasource, plugin, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) require.NoError(t, err) req, err := http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil) @@ -919,22 +915,21 @@ func runDatasourceAuthTest(t *testing.T, test *testCase) { func Test_PathCheck(t *testing.T) { // Ensure that we test routes appropriately. This test reproduces a historical bug where two routes were defined with different role requirements but the same method and the more privileged route was tested first. Here we ensure auth checks are applied based on the correct route, not just the method. - plugin := &plugins.DataSourcePlugin{ - Routes: []*plugins.AppPluginRoute{ - { - Path: "a", - URL: "https://www.google.com", - ReqRole: models.ROLE_EDITOR, - Method: http.MethodGet, - }, - { - Path: "b", - URL: "https://www.google.com", - ReqRole: models.ROLE_VIEWER, - Method: http.MethodGet, - }, + routes := []*plugins.Route{ + { + Path: "a", + URL: "https://www.google.com", + ReqRole: models.ROLE_EDITOR, + Method: http.MethodGet, + }, + { + Path: "b", + URL: "https://www.google.com", + ReqRole: models.ROLE_VIEWER, + Method: http.MethodGet, }, } + setUp := func() (*models.ReqContext, *http.Request) { req, err := http.NewRequest("GET", "http://localhost/asd", nil) require.NoError(t, err) @@ -946,11 +941,11 @@ func Test_PathCheck(t *testing.T) { } ctx, _ := setUp() dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) - proxy, err := NewDataSourceProxy(&models.DataSource{}, plugin, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) + proxy, err := NewDataSourceProxy(&models.DataSource{}, routes, ctx, "b", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService) require.NoError(t, err) require.Nil(t, proxy.validateRequest()) - require.Equal(t, plugin.Routes[1], proxy.route) + require.Equal(t, routes[1], proxy.matchedRoute) } type mockOAuthTokenService struct { diff --git a/pkg/api/pluginproxy/pluginproxy.go b/pkg/api/pluginproxy/pluginproxy.go index 3cd523a87d3..a9ad7066e9e 100644 --- a/pkg/api/pluginproxy/pluginproxy.go +++ b/pkg/api/pluginproxy/pluginproxy.go @@ -21,7 +21,7 @@ type templateData struct { } // NewApiPluginProxy create a plugin proxy -func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.AppPluginRoute, +func NewApiPluginProxy(ctx *models.ReqContext, proxyPath string, route *plugins.Route, appID string, cfg *setting.Cfg, encryptionService encryption.Service) *httputil.ReverseProxy { director := func(req *http.Request) { query := models.GetPluginSettingByIdQuery{OrgId: ctx.OrgId, PluginId: appID} diff --git a/pkg/api/pluginproxy/pluginproxy_test.go b/pkg/api/pluginproxy/pluginproxy_test.go index 7d5b4aa2d57..f59026afc0d 100644 --- a/pkg/api/pluginproxy/pluginproxy_test.go +++ b/pkg/api/pluginproxy/pluginproxy_test.go @@ -18,8 +18,8 @@ import ( func TestPluginProxy(t *testing.T) { t.Run("When getting proxy headers", func(t *testing.T) { - route := &plugins.AppPluginRoute{ - Headers: []plugins.AppPluginRouteHeader{ + route := &plugins.Route{ + Headers: []plugins.Header{ {Name: "x-header", Content: "my secret {{.SecureJsonData.key}}"}, }, } @@ -124,7 +124,7 @@ func TestPluginProxy(t *testing.T) { }) t.Run("When getting templated url", func(t *testing.T) { - route := &plugins.AppPluginRoute{ + route := &plugins.Route{ URL: "{{.JsonData.dynamicUrl}}", Method: "GET", } @@ -159,7 +159,7 @@ func TestPluginProxy(t *testing.T) { }) t.Run("When getting complex templated url", func(t *testing.T) { - route := &plugins.AppPluginRoute{ + route := &plugins.Route{ URL: "{{if .JsonData.apiHost}}{{.JsonData.apiHost}}{{else}}https://example.com{{end}}", Method: "GET", } @@ -189,7 +189,7 @@ func TestPluginProxy(t *testing.T) { }) t.Run("When getting templated body", func(t *testing.T) { - route := &plugins.AppPluginRoute{ + route := &plugins.Route{ Path: "api/body", URL: "http://www.test.com", Body: []byte(`{ "url": "{{.JsonData.dynamicUrl}}", "secret": "{{.SecureJsonData.key}}" }`), @@ -238,10 +238,10 @@ func TestPluginProxy(t *testing.T) { } // getPluginProxiedRequest is a helper for easier setup of tests based on global config and ReqContext. -func getPluginProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *setting.Cfg, route *plugins.AppPluginRoute) *http.Request { +func getPluginProxiedRequest(t *testing.T, ctx *models.ReqContext, cfg *setting.Cfg, route *plugins.Route) *http.Request { // insert dummy route if none is specified if route == nil { - route = &plugins.AppPluginRoute{ + route = &plugins.Route{ Path: "api/v4/", URL: "https://www.google.com", ReqRole: models.ROLE_EDITOR, diff --git a/pkg/api/pluginproxy/token_provider_azure.go b/pkg/api/pluginproxy/token_provider_azure.go index ff32df8f6a7..b0e3150ac87 100644 --- a/pkg/api/pluginproxy/token_provider_azure.go +++ b/pkg/api/pluginproxy/token_provider_azure.go @@ -16,7 +16,7 @@ type azureAccessTokenProvider struct { scopes []string } -func newAzureAccessTokenProvider(ctx context.Context, cfg *setting.Cfg, authParams *plugins.JwtTokenAuth) (*azureAccessTokenProvider, error) { +func newAzureAccessTokenProvider(ctx context.Context, cfg *setting.Cfg, authParams *plugins.JWTTokenAuth) (*azureAccessTokenProvider, error) { credentials := getAzureCredentials(cfg, authParams) tokenProvider, err := aztokenprovider.NewAzureAccessTokenProvider(cfg, credentials) if err != nil { @@ -33,7 +33,7 @@ func (provider *azureAccessTokenProvider) GetAccessToken() (string, error) { return provider.tokenProvider.GetAccessToken(provider.ctx, provider.scopes) } -func getAzureCredentials(cfg *setting.Cfg, authParams *plugins.JwtTokenAuth) azcredentials.AzureCredentials { +func getAzureCredentials(cfg *setting.Cfg, authParams *plugins.JWTTokenAuth) azcredentials.AzureCredentials { authType := strings.ToLower(authParams.Params["azure_auth_type"]) clientId := authParams.Params["client_id"] diff --git a/pkg/api/pluginproxy/token_provider_gce.go b/pkg/api/pluginproxy/token_provider_gce.go index 2444b53634e..966ffeda000 100644 --- a/pkg/api/pluginproxy/token_provider_gce.go +++ b/pkg/api/pluginproxy/token_provider_gce.go @@ -12,8 +12,8 @@ type gceAccessTokenProvider struct { ctx context.Context } -func newGceAccessTokenProvider(ctx context.Context, ds DSInfo, pluginRoute *plugins.AppPluginRoute, - authParams *plugins.JwtTokenAuth) *gceAccessTokenProvider { +func newGceAccessTokenProvider(ctx context.Context, ds DSInfo, pluginRoute *plugins.Route, + authParams *plugins.JWTTokenAuth) *gceAccessTokenProvider { cfg := googletokenprovider.Config{ RoutePath: pluginRoute.Path, RouteMethod: pluginRoute.Method, diff --git a/pkg/api/pluginproxy/token_provider_generic.go b/pkg/api/pluginproxy/token_provider_generic.go index 16091bf514a..a70f8b89127 100644 --- a/pkg/api/pluginproxy/token_provider_generic.go +++ b/pkg/api/pluginproxy/token_provider_generic.go @@ -27,8 +27,8 @@ type tokenCacheType struct { type genericAccessTokenProvider struct { datasourceId int64 datasourceUpdated time.Time - route *plugins.AppPluginRoute - authParams *plugins.JwtTokenAuth + route *plugins.Route + authParams *plugins.JWTTokenAuth } type jwtToken struct { @@ -67,8 +67,8 @@ func (token *jwtToken) UnmarshalJSON(b []byte) error { return nil } -func newGenericAccessTokenProvider(ds DSInfo, pluginRoute *plugins.AppPluginRoute, - authParams *plugins.JwtTokenAuth) *genericAccessTokenProvider { +func newGenericAccessTokenProvider(ds DSInfo, pluginRoute *plugins.Route, + authParams *plugins.JWTTokenAuth) *genericAccessTokenProvider { return &genericAccessTokenProvider{ datasourceId: ds.ID, datasourceUpdated: ds.Updated, diff --git a/pkg/api/pluginproxy/token_provider_jwt.go b/pkg/api/pluginproxy/token_provider_jwt.go index 97a7ca0b55e..579e50e8918 100644 --- a/pkg/api/pluginproxy/token_provider_jwt.go +++ b/pkg/api/pluginproxy/token_provider_jwt.go @@ -12,8 +12,8 @@ type jwtAccessTokenProvider struct { ctx context.Context } -func newJwtAccessTokenProvider(ctx context.Context, ds DSInfo, pluginRoute *plugins.AppPluginRoute, - authParams *plugins.JwtTokenAuth) *jwtAccessTokenProvider { +func newJwtAccessTokenProvider(ctx context.Context, ds DSInfo, pluginRoute *plugins.Route, + authParams *plugins.JWTTokenAuth) *jwtAccessTokenProvider { jwtConf := &googletokenprovider.JwtTokenConfig{} if val, ok := authParams.Params["client_email"]; ok { jwtConf.Email = val diff --git a/pkg/api/pluginproxy/token_provider_test.go b/pkg/api/pluginproxy/token_provider_test.go index baf3425bfe4..9f968e8b4d1 100644 --- a/pkg/api/pluginproxy/token_provider_test.go +++ b/pkg/api/pluginproxy/token_provider_test.go @@ -23,11 +23,11 @@ func TestAccessToken_pluginWithTokenAuthRoute(t *testing.T) { server := httptest.NewServer(apiHandler) defer server.Close() - pluginRoute := &plugins.AppPluginRoute{ + pluginRoute := &plugins.Route{ Path: "pathwithtokenauth1", URL: "", Method: "GET", - TokenAuth: &plugins.JwtTokenAuth{ + TokenAuth: &plugins.JWTTokenAuth{ Url: server.URL + "/oauth/token", Scopes: []string{ "https://www.testapi.com/auth/monitoring.read", @@ -43,7 +43,7 @@ func TestAccessToken_pluginWithTokenAuthRoute(t *testing.T) { }, } - authParams := &plugins.JwtTokenAuth{ + authParams := &plugins.JWTTokenAuth{ Url: server.URL + "/oauth/token", Scopes: []string{ "https://www.testapi.com/auth/monitoring.read", diff --git a/pkg/api/pluginproxy/utils.go b/pkg/api/pluginproxy/utils.go index bea476e2c7e..9ce875d8558 100644 --- a/pkg/api/pluginproxy/utils.go +++ b/pkg/api/pluginproxy/utils.go @@ -38,7 +38,7 @@ func interpolateString(text string, data templateData) (string, error) { } // addHeaders interpolates route headers and injects them into the request headers -func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data templateData) error { +func addHeaders(reqHeaders *http.Header, route *plugins.Route, data templateData) error { for _, header := range route.Headers { interpolated, err := interpolateString(header.Content, data) if err != nil { @@ -51,7 +51,7 @@ func addHeaders(reqHeaders *http.Header, route *plugins.AppPluginRoute, data tem } // addQueryString interpolates route params and injects them into the request object -func addQueryString(req *http.Request, route *plugins.AppPluginRoute, data templateData) error { +func addQueryString(req *http.Request, route *plugins.Route, data templateData) error { q := req.URL.Query() for _, param := range route.URLParams { interpolatedName, err := interpolateString(param.Name, data) @@ -71,7 +71,7 @@ func addQueryString(req *http.Request, route *plugins.AppPluginRoute, data templ return nil } -func setBodyContent(req *http.Request, route *plugins.AppPluginRoute, data templateData) error { +func setBodyContent(req *http.Request, route *plugins.Route, data templateData) error { if route.Body != nil { interpolatedBody, err := interpolateString(string(route.Body), data) if err != nil { diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index c49c3f683ef..d410ffdb682 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -3,15 +3,19 @@ package api import ( "encoding/json" "errors" + "fmt" + "io/ioutil" "net/http" "os" "path/filepath" "sort" + "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" @@ -31,48 +35,48 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { coreFilter = "1" } - pluginSettingsMap, err := hs.PluginManager.GetPluginSettings(c.OrgId) + pluginSettingsMap, err := hs.pluginSettings(c.OrgId) if err != nil { return response.Error(500, "Failed to get list of plugins", err) } result := make(dtos.PluginList, 0) - for _, pluginDef := range hs.PluginManager.Plugins() { + for _, pluginDef := range hs.pluginStore.Plugins() { // filter out app sub plugins - if embeddedFilter == "0" && pluginDef.IncludedInAppId != "" { + if embeddedFilter == "0" && pluginDef.IncludedInAppID != "" { continue } // filter out core plugins - if (coreFilter == "0" && pluginDef.IsCorePlugin) || (coreFilter == "1" && !pluginDef.IsCorePlugin) { + if (coreFilter == "0" && pluginDef.IsCorePlugin()) || (coreFilter == "1" && !pluginDef.IsCorePlugin()) { continue } // filter on type - if typeFilter != "" && typeFilter != pluginDef.Type { + if typeFilter != "" && typeFilter != string(pluginDef.Type) { continue } - if pluginDef.State == plugins.PluginStateAlpha && !hs.Cfg.PluginsEnableAlpha { + if pluginDef.State == plugins.AlphaRelease && !hs.Cfg.PluginsEnableAlpha { continue } listItem := dtos.PluginListItem{ - Id: pluginDef.Id, + Id: pluginDef.ID, Name: pluginDef.Name, - Type: pluginDef.Type, + Type: string(pluginDef.Type), Category: pluginDef.Category, Info: &pluginDef.Info, - LatestVersion: pluginDef.GrafanaNetVersion, - HasUpdate: pluginDef.GrafanaNetHasUpdate, - DefaultNavUrl: pluginDef.DefaultNavUrl, + LatestVersion: pluginDef.GrafanaComVersion, + HasUpdate: pluginDef.GrafanaComHasUpdate, + DefaultNavUrl: pluginDef.DefaultNavURL, State: pluginDef.State, Signature: pluginDef.Signature, SignatureType: pluginDef.SignatureType, SignatureOrg: pluginDef.SignatureOrg, } - if pluginSetting, exists := pluginSettingsMap[pluginDef.Id]; exists { + if pluginSetting, exists := pluginSettingsMap[pluginDef.ID]; exists { listItem.Enabled = pluginSetting.Enabled listItem.Pinned = pluginSetting.Pinned } @@ -86,11 +90,9 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { continue } - // filter out built in data sources - if ds := hs.PluginManager.GetDataSource(pluginDef.Id); ds != nil { - if ds.BuiltIn { - continue - } + // filter out built in plugins + if pluginDef.BuiltIn { + continue } result = append(result, listItem) @@ -103,32 +105,32 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response { func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - def := hs.PluginManager.GetPlugin(pluginID) + def := hs.pluginStore.Plugin(pluginID) if def == nil { return response.Error(404, "Plugin not found, no installed plugin with that id", nil) } dto := &dtos.PluginSetting{ - Type: def.Type, - Id: def.Id, + Type: string(def.Type), + Id: def.ID, Name: def.Name, Info: &def.Info, Dependencies: &def.Dependencies, Includes: def.Includes, - BaseUrl: def.BaseUrl, + BaseUrl: def.BaseURL, Module: def.Module, - DefaultNavUrl: def.DefaultNavUrl, - LatestVersion: def.GrafanaNetVersion, - HasUpdate: def.GrafanaNetHasUpdate, + DefaultNavUrl: def.DefaultNavURL, + LatestVersion: def.GrafanaComVersion, + HasUpdate: def.GrafanaComHasUpdate, State: def.State, Signature: def.Signature, SignatureType: def.SignatureType, SignatureOrg: def.SignatureOrg, } - if app := hs.PluginManager.GetApp(def.Id); app != nil { - dto.Enabled = app.AutoEnabled - dto.Pinned = app.AutoEnabled + if def.IsApp() { + dto.Enabled = def.AutoEnabled + dto.Pinned = def.AutoEnabled } query := models.GetPluginSettingByIdQuery{PluginId: pluginID, OrgId: c.OrgId} @@ -148,7 +150,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *models.ReqContext) response.Respon func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext, cmd models.UpdatePluginSettingCmd) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - if app := hs.PluginManager.GetApp(pluginID); app == nil { + if app := hs.pluginStore.Plugin(pluginID); app == nil { return response.Error(404, "Plugin not installed", nil) } @@ -164,9 +166,9 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext, cmd models.Updat func (hs *HTTPServer) GetPluginDashboards(c *models.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - list, err := hs.PluginManager.GetPluginDashboards(c.OrgId, pluginID) + list, err := hs.pluginDashboardManager.GetPluginDashboards(c.OrgId, pluginID) if err != nil { - var notFound plugins.PluginNotFoundError + var notFound plugins.NotFoundError if errors.As(err, ¬Found) { return response.Error(404, notFound.Error(), nil) } @@ -181,9 +183,9 @@ func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response pluginID := web.Params(c.Req)[":pluginId"] name := web.Params(c.Req)[":name"] - content, err := hs.PluginManager.GetPluginMarkdown(pluginID, name) + content, err := hs.pluginMarkdown(pluginID, name) if err != nil { - var notFound plugins.PluginNotFoundError + var notFound plugins.NotFoundError if errors.As(err, ¬Found) { return response.Error(404, notFound.Error(), nil) } @@ -193,7 +195,7 @@ func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response // fallback try readme if len(content) == 0 { - content, err = hs.PluginManager.GetPluginMarkdown(pluginID, "readme") + content, err = hs.pluginMarkdown(pluginID, "readme") if err != nil { return response.Error(501, "Could not get markdown file", err) } @@ -218,8 +220,8 @@ func (hs *HTTPServer) ImportDashboard(c *models.ReqContext, apiCmd dtos.ImportDa } } - dashInfo, dash, err := hs.PluginManager.ImportDashboard(apiCmd.PluginId, apiCmd.Path, c.OrgId, apiCmd.FolderId, - apiCmd.Dashboard, apiCmd.Overwrite, apiCmd.Inputs, c.SignedInUser, hs.DataService) + dashInfo, dash, err := hs.pluginDashboardManager.ImportDashboard(apiCmd.PluginId, apiCmd.Path, c.OrgId, apiCmd.FolderId, + apiCmd.Dashboard, apiCmd.Overwrite, apiCmd.Inputs, c.SignedInUser) if err != nil { return hs.dashboardSaveErrorToApiResponse(err) } @@ -242,12 +244,12 @@ func (hs *HTTPServer) ImportDashboard(c *models.ReqContext, apiCmd dtos.ImportDa // /api/plugins/:pluginId/metrics func (hs *HTTPServer) CollectPluginMetrics(c *models.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - plugin := hs.PluginManager.GetPlugin(pluginID) + plugin := hs.pluginStore.Plugin(pluginID) if plugin == nil { return response.Error(404, "Plugin not found", nil) } - resp, err := hs.BackendPluginManager.CollectMetrics(c.Req.Context(), plugin.Id) + resp, err := hs.pluginClient.CollectMetrics(c.Req.Context(), plugin.ID) if err != nil { return translatePluginRequestErrorToAPIError(err) } @@ -263,7 +265,7 @@ func (hs *HTTPServer) CollectPluginMetrics(c *models.ReqContext) response.Respon // /public/plugins/:pluginId/* func (hs *HTTPServer) getPluginAssets(c *models.ReqContext) { pluginID := web.Params(c.Req)[":pluginId"] - plugin := hs.PluginManager.GetPlugin(pluginID) + plugin := hs.pluginStore.Plugin(pluginID) if plugin == nil { c.JsonApiErr(404, "Plugin not found", nil) return @@ -323,7 +325,9 @@ func (hs *HTTPServer) CheckHealth(c *models.ReqContext) response.Response { return response.Error(404, "Plugin not found", nil) } - resp, err := hs.BackendPluginManager.CheckHealth(c.Req.Context(), pCtx) + resp, err := hs.pluginClient.CheckHealth(c.Req.Context(), &backend.CheckHealthRequest{ + PluginContext: pCtx, + }) if err != nil { return translatePluginRequestErrorToAPIError(err) } @@ -366,19 +370,19 @@ func (hs *HTTPServer) CallResource(c *models.ReqContext) { c.JsonApiErr(404, "Plugin not found", nil) return } - hs.BackendPluginManager.CallResource(pCtx, c, web.Params(c.Req)["*"]) + hs.pluginClient.CallResource(pCtx, c, web.Params(c.Req)["*"]) } func (hs *HTTPServer) GetPluginErrorsList(_ *models.ReqContext) response.Response { - return response.JSON(200, hs.PluginManager.ScanningErrors()) + return response.JSON(200, hs.pluginErrorResolver.PluginErrors()) } func (hs *HTTPServer) InstallPlugin(c *models.ReqContext, dto dtos.InstallPluginCommand) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - err := hs.PluginManager.Install(c.Req.Context(), pluginID, dto.Version) + err := hs.pluginStore.Add(c.Req.Context(), pluginID, dto.Version, plugins.AddOpts{}) if err != nil { - var dupeErr plugins.DuplicatePluginError + var dupeErr plugins.DuplicateError if errors.As(err, &dupeErr) { return response.Error(http.StatusConflict, "Plugin already installed", err) } @@ -407,7 +411,7 @@ func (hs *HTTPServer) InstallPlugin(c *models.ReqContext, dto dtos.InstallPlugin func (hs *HTTPServer) UninstallPlugin(c *models.ReqContext) response.Response { pluginID := web.Params(c.Req)[":pluginId"] - err := hs.PluginManager.Uninstall(c.Req.Context(), pluginID) + err := hs.pluginStore.Remove(c.Req.Context(), pluginID) if err != nil { if errors.Is(err, plugins.ErrPluginNotInstalled) { return response.Error(http.StatusNotFound, "Plugin not installed", err) @@ -443,3 +447,39 @@ func translatePluginRequestErrorToAPIError(err error) response.Response { return response.Error(500, "Plugin request failed", err) } + +func (hs *HTTPServer) pluginMarkdown(pluginId string, name string) ([]byte, error) { + plug := hs.pluginStore.Plugin(pluginId) + if plug == nil { + return nil, plugins.NotFoundError{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 +} diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 2814bea3ad1..1f80a5d4620 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -15,7 +15,6 @@ import ( "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/manager" "github.com/grafana/grafana/pkg/setting" ) @@ -35,15 +34,17 @@ func Test_GetPluginAssets(t *testing.T) { requestedFile := filepath.Clean(tmpFile.Name()) t.Run("Given a request for an existing plugin file that is listed as a signature covered file", func(t *testing.T) { - p := &plugins.PluginBase{ - Id: pluginID, + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, PluginDir: pluginDir, SignedFiles: map[string]struct{}{ requestedFile: {}, }, } - service := &pluginManager{ - plugins: map[string]*plugins.PluginBase{ + service := &pluginStore{ + plugins: map[string]*plugins.Plugin{ pluginID: p, }, } @@ -61,12 +62,14 @@ func Test_GetPluginAssets(t *testing.T) { }) t.Run("Given a request for an existing plugin file that is not listed as a signature covered file", func(t *testing.T) { - p := &plugins.PluginBase{ - Id: pluginID, + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, PluginDir: pluginDir, } - service := &pluginManager{ - plugins: map[string]*plugins.PluginBase{ + service := &pluginStore{ + plugins: map[string]*plugins.Plugin{ pluginID: p, }, } @@ -84,12 +87,14 @@ func Test_GetPluginAssets(t *testing.T) { }) t.Run("Given a request for an non-existing plugin file", func(t *testing.T) { - p := &plugins.PluginBase{ - Id: pluginID, + p := &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, PluginDir: pluginDir, } - service := &pluginManager{ - plugins: map[string]*plugins.PluginBase{ + service := &pluginStore{ + plugins: map[string]*plugins.Plugin{ pluginID: p, }, } @@ -111,8 +116,8 @@ func Test_GetPluginAssets(t *testing.T) { }) t.Run("Given a request for an non-existing plugin", func(t *testing.T) { - service := &pluginManager{ - plugins: map[string]*plugins.PluginBase{}, + service := &pluginStore{ + plugins: map[string]*plugins.Plugin{}, } l := &logger{} @@ -132,10 +137,10 @@ func Test_GetPluginAssets(t *testing.T) { }) t.Run("Given a request for a core plugin's file", func(t *testing.T) { - service := &pluginManager{ - plugins: map[string]*plugins.PluginBase{ + service := &pluginStore{ + plugins: map[string]*plugins.Plugin{ pluginID: { - IsCorePlugin: true, + Class: plugins.Core, }, }, } @@ -157,15 +162,15 @@ func callGetPluginAsset(sc *scenarioContext) { sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() } -func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginManager plugins.Manager, +func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store, logger log.Logger, fn scenarioFunc) { t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { defer bus.ClearBusHandlers() hs := HTTPServer{ - Cfg: setting.NewCfg(), - PluginManager: pluginManager, - log: logger, + Cfg: setting.NewCfg(), + pluginStore: pluginStore, + log: logger, } sc := setupScenarioContext(t, url) @@ -180,13 +185,13 @@ func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern strin }) } -type pluginManager struct { - manager.PluginManager +type pluginStore struct { + plugins.Store - plugins map[string]*plugins.PluginBase + plugins map[string]*plugins.Plugin } -func (pm *pluginManager) GetPlugin(id string) *plugins.PluginBase { +func (pm *pluginStore) Plugin(id string) *plugins.Plugin { return pm.plugins[id] } diff --git a/pkg/infra/usagestats/service/service.go b/pkg/infra/usagestats/service/service.go index 6ec6165b87d..112799a739c 100644 --- a/pkg/infra/usagestats/service/service.go +++ b/pkg/infra/usagestats/service/service.go @@ -19,7 +19,7 @@ type UsageStats struct { Cfg *setting.Cfg Bus bus.Bus SQLStore *sqlstore.SQLStore - PluginManager plugins.Manager + pluginStore plugins.Store SocialService social.Service kvStore *kvstore.NamespacedKVStore @@ -32,14 +32,14 @@ type UsageStats struct { sendReportCallbacks []usagestats.SendReportCallbackFunc } -func ProvideService(cfg *setting.Cfg, bus bus.Bus, sqlStore *sqlstore.SQLStore, pluginManager plugins.Manager, +func ProvideService(cfg *setting.Cfg, bus bus.Bus, sqlStore *sqlstore.SQLStore, pluginStore plugins.Store, socialService social.Service, kvStore kvstore.KVStore) *UsageStats { s := &UsageStats{ Cfg: cfg, Bus: bus, SQLStore: sqlStore, oauthProviders: socialService.GetOAuthProviders(), - PluginManager: pluginManager, + pluginStore: pluginStore, kvStore: kvstore.WithNamespace(kvStore, 0, "infra.usagestats"), log: log.New("infra.usagestats"), startTime: time.Now(), diff --git a/pkg/infra/usagestats/service/usage_stats.go b/pkg/infra/usagestats/service/usage_stats.go index 93569f26db8..b212e1141cf 100644 --- a/pkg/infra/usagestats/service/usage_stats.go +++ b/pkg/infra/usagestats/service/usage_stats.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" ) var usageStatsURL = "https://stats.grafana.org/grafana-usage-report" @@ -50,9 +51,9 @@ func (uss *UsageStats) GetUsageReport(ctx context.Context) (usagestats.Report, e metrics["stats.viewers.count"] = statsQuery.Result.Viewers metrics["stats.orgs.count"] = statsQuery.Result.Orgs metrics["stats.playlist.count"] = statsQuery.Result.Playlists - metrics["stats.plugins.apps.count"] = uss.PluginManager.AppCount() - metrics["stats.plugins.panels.count"] = uss.PluginManager.PanelCount() - metrics["stats.plugins.datasources.count"] = uss.PluginManager.DataSourceCount() + metrics["stats.plugins.apps.count"] = uss.appCount() + metrics["stats.plugins.panels.count"] = uss.panelCount() + metrics["stats.plugins.datasources.count"] = uss.dataSourceCount() metrics["stats.alerts.count"] = statsQuery.Result.Alerts metrics["stats.active_users.count"] = statsQuery.Result.ActiveUsers metrics["stats.active_admins.count"] = statsQuery.Result.ActiveAdmins @@ -328,7 +329,7 @@ func (uss *UsageStats) updateTotalStats(ctx context.Context) { } func (uss *UsageStats) ShouldBeReported(dsType string) bool { - ds := uss.PluginManager.GetDataSource(dsType) + ds := uss.pluginStore.Plugin(dsType) if ds == nil { return false } @@ -363,3 +364,15 @@ func (uss *UsageStats) GetUsageStatsId(ctx context.Context) string { return anonId } + +func (uss *UsageStats) appCount() int { + return len(uss.pluginStore.Plugins(plugins.App)) +} + +func (uss *UsageStats) panelCount() int { + return len(uss.pluginStore.Plugins(plugins.Panel)) +} + +func (uss *UsageStats) dataSourceCount() int { + return len(uss.pluginStore.Plugins(plugins.DataSource)) +} diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index 3b46008a01a..01c449b4ba9 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -18,7 +18,6 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" @@ -318,9 +317,9 @@ func TestMetrics(t *testing.T) { assert.Equal(t, getSystemStatsQuery.Result.Viewers, metrics.Get("stats.viewers.count").MustInt64()) assert.Equal(t, getSystemStatsQuery.Result.Orgs, metrics.Get("stats.orgs.count").MustInt64()) assert.Equal(t, getSystemStatsQuery.Result.Playlists, metrics.Get("stats.playlist.count").MustInt64()) - assert.Equal(t, uss.PluginManager.AppCount(), metrics.Get("stats.plugins.apps.count").MustInt()) - assert.Equal(t, uss.PluginManager.PanelCount(), metrics.Get("stats.plugins.panels.count").MustInt()) - assert.Equal(t, uss.PluginManager.DataSourceCount(), metrics.Get("stats.plugins.datasources.count").MustInt()) + assert.Equal(t, uss.appCount(), metrics.Get("stats.plugins.apps.count").MustInt()) + assert.Equal(t, uss.panelCount(), metrics.Get("stats.plugins.panels.count").MustInt()) + assert.Equal(t, uss.dataSourceCount(), metrics.Get("stats.plugins.datasources.count").MustInt()) assert.Equal(t, getSystemStatsQuery.Result.Alerts, metrics.Get("stats.alerts.count").MustInt64()) assert.Equal(t, getSystemStatsQuery.Result.ActiveUsers, metrics.Get("stats.active_users.count").MustInt64()) assert.Equal(t, getSystemStatsQuery.Result.ActiveAdmins, metrics.Get("stats.active_admins.count").MustInt64()) @@ -552,57 +551,45 @@ func TestMetrics(t *testing.T) { }) } -type fakePluginManager struct { - manager.PluginManager +type fakePluginStore struct { + plugins.Store - dataSources map[string]*plugins.DataSourcePlugin - panels map[string]*plugins.PanelPlugin + plugins map[string]*plugins.Plugin } -func (pm *fakePluginManager) DataSourceCount() int { - return len(pm.dataSources) +func (pr fakePluginStore) Plugin(pluginID string) *plugins.Plugin { + return pr.plugins[pluginID] } -func (pm *fakePluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { - return pm.dataSources[id] -} +func (pr fakePluginStore) Plugins(pluginTypes ...plugins.Type) []*plugins.Plugin { + var result []*plugins.Plugin + for _, v := range pr.plugins { + for _, t := range pluginTypes { + if v.Type == t { + result = append(result, v) + } + } + } -func (pm *fakePluginManager) PanelCount() int { - return len(pm.panels) + return result } func setupSomeDataSourcePlugins(t *testing.T, uss *UsageStats) { t.Helper() - uss.PluginManager = &fakePluginManager{ - dataSources: map[string]*plugins.DataSourcePlugin{ + uss.pluginStore = &fakePluginStore{ + plugins: map[string]*plugins.Plugin{ models.DS_ES: { - FrontendPluginBase: plugins.FrontendPluginBase{ - PluginBase: plugins.PluginBase{ - Signature: "internal", - }, - }, + Signature: "internal", }, models.DS_PROMETHEUS: { - FrontendPluginBase: plugins.FrontendPluginBase{ - PluginBase: plugins.PluginBase{ - Signature: "internal", - }, - }, + Signature: "internal", }, models.DS_GRAPHITE: { - FrontendPluginBase: plugins.FrontendPluginBase{ - PluginBase: plugins.PluginBase{ - Signature: "internal", - }, - }, + Signature: "internal", }, models.DS_MYSQL: { - FrontendPluginBase: plugins.FrontendPluginBase{ - PluginBase: plugins.PluginBase{ - Signature: "internal", - }, - }, + Signature: "internal", }, }, } @@ -624,7 +611,7 @@ func createService(t *testing.T, cfg setting.Cfg) *UsageStats { Cfg: &cfg, SQLStore: sqlStore, externalMetrics: make([]usagestats.MetricsFunc, 0), - PluginManager: &fakePluginManager{}, + pluginStore: &fakePluginStore{}, kvStore: kvstore.WithNamespace(kvstore.ProvideService(sqlStore), 0, "infra.usagestats"), log: log.New("infra.usagestats"), startTime: time.Now().Add(-1 * time.Minute), diff --git a/pkg/plugins/app_plugin.go b/pkg/plugins/app_plugin.go deleted file mode 100644 index a5d1f5f738a..00000000000 --- a/pkg/plugins/app_plugin.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/backend_utils.go b/pkg/plugins/backend_utils.go index ef5c3564b97..075635e9ef4 100644 --- a/pkg/plugins/backend_utils.go +++ b/pkg/plugins/backend_utils.go @@ -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) +} diff --git a/pkg/plugins/backendplugin/ifaces.go b/pkg/plugins/backendplugin/ifaces.go index cf190153ec8..7ee21d9dcc5 100644 --- a/pkg/plugins/backendplugin/ifaces.go +++ b/pkg/plugins/backendplugin/ifaces.go @@ -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 diff --git a/pkg/plugins/backendplugin/instrumentation/instrumentation.go b/pkg/plugins/backendplugin/instrumentation/instrumentation.go index dfdf21dfcba..21831ece3f0 100644 --- a/pkg/plugins/backendplugin/instrumentation/instrumentation.go +++ b/pkg/plugins/backendplugin/instrumentation/instrumentation.go @@ -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 - }) -} diff --git a/pkg/plugins/backendplugin/manager/manager.go b/pkg/plugins/backendplugin/manager/manager.go deleted file mode 100644 index b4976a0d850..00000000000 --- a/pkg/plugins/backendplugin/manager/manager.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/backendplugin/manager/manager_test.go b/pkg/plugins/backendplugin/manager/manager_test.go deleted file mode 100644 index 74ac2661dd1..00000000000 --- a/pkg/plugins/backendplugin/manager/manager_test.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/backendplugin/manager/plugin_settings.go b/pkg/plugins/backendplugin/manager/plugin_settings.go deleted file mode 100644 index b2f2e07d72a..00000000000 --- a/pkg/plugins/backendplugin/manager/plugin_settings.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/backendplugin/manager/plugin_settings_test.go b/pkg/plugins/backendplugin/manager/plugin_settings_test.go deleted file mode 100644 index b9e77e70956..00000000000 --- a/pkg/plugins/backendplugin/manager/plugin_settings_test.go +++ /dev/null @@ -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) - }) - }) -} diff --git a/pkg/plugins/backendplugin/manager/resource_response_stream.go b/pkg/plugins/backendplugin/manager/resource_response_stream.go deleted file mode 100644 index 31ab51f0be6..00000000000 --- a/pkg/plugins/backendplugin/manager/resource_response_stream.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go deleted file mode 100644 index 0827e88a5b5..00000000000 --- a/pkg/plugins/datasource_plugin.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/error.go b/pkg/plugins/error.go deleted file mode 100644 index 207d6c687ec..00000000000 --- a/pkg/plugins/error.go +++ /dev/null @@ -1,8 +0,0 @@ -package plugins - -type ErrorCode string - -type PluginError struct { - ErrorCode `json:"errorCode"` - PluginID string `json:"pluginId,omitempty"` -} diff --git a/pkg/plugins/frontend_plugin.go b/pkg/plugins/frontend_plugin.go deleted file mode 100644 index 229fb05b8ee..00000000000 --- a/pkg/plugins/frontend_plugin.go +++ /dev/null @@ -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// - // 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) -} diff --git a/pkg/plugins/frontend_plugin_test.go b/pkg/plugins/frontend_plugin_test.go deleted file mode 100644 index 030c7c26ec0..00000000000 --- a/pkg/plugins/frontend_plugin_test.go +++ /dev/null @@ -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) - }) -} diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index f9759aebdda..6472afd0505 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -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{}) -} diff --git a/pkg/plugins/manager/dashboard_import.go b/pkg/plugins/manager/dashboard_import.go index 442ea19484b..dac03746be2 100644 --- a/pkg/plugins/manager/dashboard_import.go +++ b/pkg/plugins/manager/dashboard_import.go @@ -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 diff --git a/pkg/plugins/manager/dashboard_import_test.go b/pkg/plugins/manager/dashboard_import_test.go index e06e00983ba..e8172b65f98 100644 --- a/pkg/plugins/manager/dashboard_import_test.go +++ b/pkg/plugins/manager/dashboard_import_test.go @@ -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) diff --git a/pkg/plugins/manager/dashboards.go b/pkg/plugins/manager/dashboards.go index 4e651c3417e..6fba9f02367 100644 --- a/pkg/plugins/manager/dashboards.go +++ b/pkg/plugins/manager/dashboards.go @@ -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 +} diff --git a/pkg/plugins/manager/dashboards_test.go b/pkg/plugins/manager/dashboards_test.go index 65456096bd5..f1b6b9f7141 100644 --- a/pkg/plugins/manager/dashboards_test.go +++ b/pkg/plugins/manager/dashboards_test.go @@ -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) diff --git a/pkg/plugins/manager/errors.go b/pkg/plugins/manager/errors.go deleted file mode 100644 index f5543bf82d0..00000000000 --- a/pkg/plugins/manager/errors.go +++ /dev/null @@ -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" -) diff --git a/pkg/plugins/manager/installer/ifaces.go b/pkg/plugins/manager/installer/ifaces.go new file mode 100644 index 00000000000..9de8c07adfc --- /dev/null +++ b/pkg/plugins/manager/installer/ifaces.go @@ -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{}) +} diff --git a/pkg/plugins/manager/installer/installer.go b/pkg/plugins/manager/installer/installer.go index 999972c86c1..dac982a2f8e 100644 --- a/pkg/plugins/manager/installer/installer.go +++ b/pkg/plugins/manager/installer/installer.go @@ -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 diff --git a/pkg/plugins/manager/loader/finder/finder.go b/pkg/plugins/manager/loader/finder/finder.go new file mode 100644 index 00000000000..4ceb2287a05 --- /dev/null +++ b/pkg/plugins/manager/loader/finder/finder.go @@ -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 +} diff --git a/pkg/plugins/manager/loader/finder/finder_test.go b/pkg/plugins/manager/loader/finder/finder_test.go new file mode 100644 index 00000000000..db401563985 --- /dev/null +++ b/pkg/plugins/manager/loader/finder/finder_test.go @@ -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])) + } + }) + } +} diff --git a/pkg/plugins/manager/loader/initializer/initializer.go b/pkg/plugins/manager/loader/initializer/initializer.go new file mode 100644 index 00000000000..4f9db49169d --- /dev/null +++ b/pkg/plugins/manager/loader/initializer/initializer.go @@ -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// + // 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 +} diff --git a/pkg/plugins/manager/loader/initializer/initializer_test.go b/pkg/plugins/manager/loader/initializer/initializer_test.go new file mode 100644 index 00000000000..8e93158a3fb --- /dev/null +++ b/pkg/plugins/manager/loader/initializer/initializer_test.go @@ -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 +} diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go new file mode 100644 index 00000000000..3fae1fb07e1 --- /dev/null +++ b/pkg/plugins/manager/loader/loader.go @@ -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{}{} + } +} diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go new file mode 100644 index 00000000000..d271a927074 --- /dev/null +++ b/pkg/plugins/manager/loader/loader_test.go @@ -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} +} diff --git a/pkg/plugins/manager/logger.go b/pkg/plugins/manager/logger.go index dc8fe9145f2..3352d38b673 100644 --- a/pkg/plugins/manager/logger.go +++ b/pkg/plugins/manager/logger.go @@ -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), diff --git a/pkg/plugins/manager/manager.go b/pkg/plugins/manager/manager.go index 3efbb2bdfbe..099a1d425a4 100644 --- a/pkg/plugins/manager/manager.go +++ b/pkg/plugins/manager/manager.go @@ -1,4 +1,3 @@ -// Package manager contains plugin manager logic. package manager import ( @@ -6,740 +5,508 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" + "net/http" + "net/url" "os" "path/filepath" - "reflect" - "runtime" "strings" "sync" "time" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "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/plugins/backendplugin/instrumentation" "github.com/grafana/grafana/pkg/plugins/manager/installer" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util/errutil" -) - -var ( - plog log.Logger - installerLog = NewInstallerLogger("plugin.installer", true) + "github.com/grafana/grafana/pkg/util/proxyutil" ) const ( grafanaComURL = "https://grafana.com/api/plugins" ) -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 -} +var _ plugins.Client = (*PluginManager)(nil) +var _ plugins.Store = (*PluginManager)(nil) +var _ plugins.PluginDashboardManager = (*PluginManager)(nil) +var _ plugins.StaticRouteResolver = (*PluginManager)(nil) +var _ plugins.CoreBackendRegistrar = (*PluginManager)(nil) +var _ plugins.RendererManager = (*PluginManager)(nil) type PluginManager struct { - BackendPluginManager backendplugin.Manager - Cfg *setting.Cfg - SQLStore *sqlstore.SQLStore - pluginInstaller plugins.PluginInstaller - 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 - - renderer *plugins.RendererPlugin - dataSources map[string]*plugins.DataSourcePlugin - plugins map[string]*plugins.PluginBase - panels map[string]*plugins.PanelPlugin - apps map[string]*plugins.AppPlugin - staticRoutes []*plugins.PluginStaticRoute - pluginsMu sync.RWMutex + cfg *setting.Cfg + requestValidator models.PluginRequestValidator + sqlStore *sqlstore.SQLStore + plugins map[string]*plugins.Plugin + pluginInstaller plugins.Installer + pluginLoader plugins.Loader + pluginsMu sync.RWMutex + log log.Logger } -func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, backendPM backendplugin.Manager) (*PluginManager, error) { - pm := newManager(cfg, sqlStore, backendPM) +func ProvideService(cfg *setting.Cfg, requestValidator models.PluginRequestValidator, pluginLoader plugins.Loader, + sqlStore *sqlstore.SQLStore) (*PluginManager, error) { + pm := newManager(cfg, requestValidator, pluginLoader, sqlStore) if err := pm.init(); err != nil { return nil, err } return pm, nil } -func newManager(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, backendPM backendplugin.Manager) *PluginManager { +func newManager(cfg *setting.Cfg, pluginRequestValidator models.PluginRequestValidator, pluginLoader plugins.Loader, + sqlStore *sqlstore.SQLStore) *PluginManager { return &PluginManager{ - Cfg: cfg, - SQLStore: sqlStore, - BackendPluginManager: backendPM, - dataSources: map[string]*plugins.DataSourcePlugin{}, - plugins: map[string]*plugins.PluginBase{}, - panels: map[string]*plugins.PanelPlugin{}, - apps: map[string]*plugins.AppPlugin{}, - pluginScanningErrors: map[string]plugins.PluginError{}, - log: log.New("plugins"), + cfg: cfg, + requestValidator: pluginRequestValidator, + sqlStore: sqlStore, + pluginLoader: pluginLoader, + plugins: map[string]*plugins.Plugin{}, + log: log.New("plugin.manager"), + pluginInstaller: installer.New(false, cfg.BuildVersion, newInstallerLogger("plugin.installer", true)), } } -func (pm *PluginManager) init() error { - plog = log.New("plugins") - pm.pluginInstaller = installer.New(false, pm.Cfg.BuildVersion, installerLog) - - 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) - } - } - - return pm.initExternalPlugins() -} - -func (pm *PluginManager) initExternalPlugins() error { - // check if plugins dir exists - exists, err := fs.Exists(pm.Cfg.PluginsPath) +func (m *PluginManager) init() error { + // create external plugin's path if not exists + exists, err := fs.Exists(m.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) + if err = os.MkdirAll(m.cfg.PluginsPath, os.ModePerm); err != nil { + m.log.Error("Failed to create external plugins directory", "dir", m.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) + m.log.Debug("External plugins directory created", "dir", m.cfg.PluginsPath) } } - if err := pm.scanPluginPaths(); err != nil { + m.log.Info("Initialising plugins") + + // install Core plugins + err = m.loadPlugins(m.corePluginPaths()...) + if err != nil { return err } - var staticRoutesList []*plugins.PluginStaticRoute - for _, panel := range pm.Panels() { - staticRoutes := panel.InitFrontendPlugin(pm.Cfg) - staticRoutesList = append(staticRoutesList, staticRoutes...) + // install Bundled plugins + err = m.loadPlugins(m.cfg.BundledPluginsPath) + if err != nil { + return err } - for _, ds := range pm.DataSources() { - staticRoutes := ds.InitFrontendPlugin(pm.Cfg) - staticRoutesList = append(staticRoutesList, staticRoutes...) + // install External plugins + err = m.loadPlugins(m.cfg.PluginsPath) + if err != nil { + return err } - for _, app := range pm.Apps() { - staticRoutes := app.InitApp(pm.panels, pm.dataSources, pm.Cfg) - staticRoutesList = append(staticRoutesList, staticRoutes...) - } - - if pm.Renderer() != nil { - staticRoutes := pm.renderer.InitFrontendPlugin(pm.Cfg) - staticRoutesList = append(staticRoutesList, staticRoutes...) - } - pm.staticRoutes = staticRoutesList - - for _, p := range pm.Plugins() { - if p.IsCorePlugin { - p.Signature = plugins.PluginSignatureInternal - } else { - metrics.SetPluginBuildInformation(p.Id, p.Type, p.Info.Version, string(p.Signature)) - } + // install plugins from cfg.PluginSettings + err = m.loadPlugins(m.pluginSettingPaths()...) + if err != nil { + return err } return nil } -func (pm *PluginManager) Run(ctx context.Context) error { - pm.checkForUpdates() +func (m *PluginManager) Run(ctx context.Context) error { + if m.cfg.CheckForUpdates { + go func() { + m.checkForUpdates() - ticker := time.NewTicker(time.Minute * 10) - run := true + ticker := time.NewTicker(time.Minute * 10) + run := true - for run { - select { - case <-ticker.C: - pm.checkForUpdates() - case <-ctx.Done(): - run = false - } + for run { + select { + case <-ticker.C: + m.checkForUpdates() + case <-ctx.Done(): + run = false + } + } + }() } + <-ctx.Done() + m.stop(ctx) return ctx.Err() } -func (pm *PluginManager) Renderer() *plugins.RendererPlugin { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return pm.renderer -} - -func (pm *PluginManager) GetDataSource(id string) *plugins.DataSourcePlugin { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return pm.dataSources[id] -} - -func (pm *PluginManager) DataSources() []*plugins.DataSourcePlugin { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - var rslt []*plugins.DataSourcePlugin - for _, ds := range pm.dataSources { - rslt = append(rslt, ds) +func (m *PluginManager) loadPlugins(paths ...string) error { + if len(paths) == 0 { + return nil } - return rslt -} - -func (pm *PluginManager) DataSourceCount() int { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return len(pm.dataSources) -} - -func (pm *PluginManager) PanelCount() int { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return len(pm.panels) -} - -func (pm *PluginManager) AppCount() int { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return len(pm.apps) -} - -func (pm *PluginManager) Plugins() []*plugins.PluginBase { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - var rslt []*plugins.PluginBase - for _, p := range pm.plugins { - rslt = append(rslt, p) - } - - return rslt -} - -func (pm *PluginManager) Apps() []*plugins.AppPlugin { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - var rslt []*plugins.AppPlugin - for _, p := range pm.apps { - rslt = append(rslt, p) - } - - return rslt -} - -func (pm *PluginManager) Panels() []*plugins.PanelPlugin { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - var rslt []*plugins.PanelPlugin - for _, p := range pm.panels { - rslt = append(rslt, p) - } - - return rslt -} - -func (pm *PluginManager) GetPlugin(id string) *plugins.PluginBase { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return pm.plugins[id] -} - -func (pm *PluginManager) GetApp(id string) *plugins.AppPlugin { - pm.pluginsMu.RLock() - defer pm.pluginsMu.RUnlock() - - return pm.apps[id] -} - -func (pm *PluginManager) GrafanaLatestVersion() string { - return pm.grafanaLatestVersion -} - -func (pm *PluginManager) GrafanaHasUpdate() bool { - return pm.grafanaHasUpdate -} - -// 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 + var pluginPaths []string + for _, p := range paths { + if p != "" { + pluginPaths = append(pluginPaths, p) } + } - if err := pm.scan(path, true); err != nil { - return errutil.Wrapf(err, "failed to scan directory configured for plugin '%s': '%s'", pluginID, path) + loadedPlugins, err := m.pluginLoader.Load(pluginPaths, m.registeredPlugins()) + if err != nil { + m.log.Error("Could not load plugins", "paths", pluginPaths, "err", err) + return err + } + + for _, p := range loadedPlugins { + if err := m.registerAndStart(context.Background(), p); err != nil { + m.log.Error("Could not start plugin", "pluginId", p.ID, "err", err) } } 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") - +func (m *PluginManager) registeredPlugins() map[string]struct{} { pluginsByID := make(map[string]struct{}) - for scannedPluginPath, scannedPlugin := range scanner.plugins { - // Check if scanning found duplicate plugins - if _, dupe := pluginsByID[scannedPlugin.Id]; dupe { - pm.log.Warn("Skipping plugin as it's a duplicate", "id", scannedPlugin.Id) - scanner.errors = append(scanner.errors, - plugins.DuplicatePluginError{PluginID: scannedPlugin.Id, ExistingPluginDir: scannedPlugin.PluginDir}) - delete(scanner.plugins, scannedPluginPath) - continue - } - pluginsByID[scannedPlugin.Id] = struct{}{} - // Check if scanning found plugins that are already installed - if existing := pm.GetPlugin(scannedPlugin.Id); existing != nil { - pm.log.Debug("Skipping plugin as it's already installed", "plugin", existing.Id, "version", existing.Info.Version) - delete(scanner.plugins, scannedPluginPath) + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() + for _, p := range m.plugins { + pluginsByID[p.ID] = struct{}{} + } + + return pluginsByID +} + +func (m *PluginManager) Plugin(pluginID string) *plugins.Plugin { + m.pluginsMu.RLock() + p, ok := m.plugins[pluginID] + m.pluginsMu.RUnlock() + + if ok && (p.IsDecommissioned()) { + return nil + } + + return p +} + +func (m *PluginManager) Plugins(pluginTypes ...plugins.Type) []*plugins.Plugin { + // if no types passed, assume all + if len(pluginTypes) == 0 { + pluginTypes = plugins.PluginTypes + } + + var requestedTypes = make(map[plugins.Type]struct{}) + for _, pt := range pluginTypes { + requestedTypes[pt] = struct{}{} + } + + m.pluginsMu.RLock() + var pluginsList []*plugins.Plugin + for _, p := range m.plugins { + if _, exists := requestedTypes[p.Type]; exists { + pluginsList = append(pluginsList, p) + } + } + m.pluginsMu.RUnlock() + return pluginsList +} + +func (m *PluginManager) Renderer() *plugins.Plugin { + for _, p := range m.plugins { + if p.IsRenderer() { + return p } } - pluginTypes := map[string]interface{}{ - "panel": plugins.PanelPlugin{}, - "datasource": plugins.DataSourcePlugin{}, - "app": plugins.AppPlugin{}, - "renderer": plugins.RendererPlugin{}, + return nil +} + +func (m *PluginManager) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + plugin := m.Plugin(req.PluginContext.PluginID) + if plugin == nil { + return &backend.QueryDataResponse{}, nil } - // 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 = "/" + var resp *backend.QueryDataResponse + err := instrumentation.InstrumentQueryDataRequest(req.PluginContext.PluginID, func() (innerErr error) { + resp, innerErr = plugin.QueryData(ctx, req) + return + }) + + if err != nil { + if errors.Is(err, backendplugin.ErrMethodNotImplemented) { + return nil, err } - for _, a := range ancestors { - aPath = filepath.Join(aPath, a) - if root, ok := scanner.plugins[aPath]; ok { - plugin.Root = root - break + + if errors.Is(err, backendplugin.ErrPluginUnavailable) { + return nil, err + } + + return nil, errutil.Wrap("failed to query data", err) + } + + for refID, res := range resp.Responses { + // set frame ref ID based on response ref ID + for _, f := range res.Frames { + if f.RefID == "" { + f.RefID = refID } } + } - 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 - } + return resp, err +} - pm.log.Debug("Attempting to add plugin", "id", plugin.Id) +func (m *PluginManager) CallResource(pCtx backend.PluginContext, reqCtx *models.ReqContext, path string) { + var dsURL string + if pCtx.DataSourceInstanceSettings != nil { + dsURL = pCtx.DataSourceInstanceSettings.URL + } - pluginGoType, exists := pluginTypes[plugin.Type] - if !exists { - return fmt.Errorf("unknown plugin type %q", plugin.Type) - } + err := m.requestValidator.Validate(dsURL, reqCtx.Req) + if err != nil { + reqCtx.JsonApiErr(http.StatusForbidden, "Access denied", err) + return + } - jsonFPath := filepath.Join(plugin.PluginDir, "plugin.json") + 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) + } +} - // 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) - } - } +func (m *PluginManager) callResourceInternal(w http.ResponseWriter, req *http.Request, pCtx backend.PluginContext) error { + p := m.Plugin(pCtx.PluginID) + if p == nil { + return backendplugin.ErrPluginNotRegistered + } - // 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) + keepCookieModel := keepCookiesJSONModel{} + if dis := pCtx.DataSourceInstanceSettings; dis != nil { + err := json.Unmarshal(dis.JSONData, &keepCookieModel) if err != nil { - return err + p.Logger().Error("Failed to to unpack JSONData in datasource instance settings", "err", 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 := reader.Close(); err != nil { - scanner.log.Warn("Failed to close JSON file", "path", jsonFPath, "err", err) + if err := stream.Close(); err != nil { + m.log.Warn("Failed to close stream", "err", err) } + wg.Wait() }() - jsonParser := json.NewDecoder(reader) + var flushStreamErr error + go func() { + flushStreamErr = flushStream(p, stream, w) + wg.Done() + }() - 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 { + if err := p.CallResource(req.Context(), crReq, stream); err != nil { return err } - } - if len(scanner.errors) > 0 { - var errStr []string - for _, err := range scanner.errors { - errStr = append(errStr, err.Error()) - } - pm.log.Warn("Some plugin scanning errors were found", "errors", strings.Join(errStr, ", ")) - pm.scanningErrors = scanner.errors - } - - return nil + return flushStreamErr + }) } -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 +func handleCallResourceError(err error, reqCtx *models.ReqContext) { + if errors.Is(err, backendplugin.ErrPluginUnavailable) { + reqCtx.JsonApiErr(503, "Plugin unavailable", err) + return } - pm.pluginsMu.Lock() - defer pm.pluginsMu.Unlock() - - var pb *plugins.PluginBase - switch p := plug.(type) { - case *plugins.DataSourcePlugin: - pm.dataSources[p.Id] = p - pb = &p.PluginBase - case *plugins.PanelPlugin: - pm.panels[p.Id] = p - pb = &p.PluginBase - case *plugins.RendererPlugin: - pm.renderer = p - pb = &p.PluginBase - case *plugins.AppPlugin: - pm.apps[p.Id] = p - pb = &p.PluginBase - default: - panic(fmt.Sprintf("Unrecognized plugin type %T", plug)) + if errors.Is(err, backendplugin.ErrMethodNotImplemented) { + reqCtx.JsonApiErr(404, "Not found", err) + return } - 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 - pb.SignedFiles = pluginBase.SignedFiles - - pm.plugins[pb.Id] = pb - pm.log.Debug("Successfully added plugin", "id", pb.Id) - return nil + reqCtx.JsonApiErr(500, "Failed to call resource", err) } -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) - } +func flushStream(plugin backendplugin.Plugin, stream callResourceClientResponseStream, w http.ResponseWriter) error { + processedStreams := 0 - if f.Name() == "node_modules" { - 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) - 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 - pluginCommon.SignedFiles = signatureState.Files - - 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 + for { + resp, err := stream.Recv() + if errors.Is(err, io.EOF) { + if processedStreams == 0 { + return errors.New("received empty resource response") } + return nil } - } else { - s.log.Debug("Non-valid plugin Signature", "pluginID", plugin.Id, "pluginDir", plugin.PluginDir, - "state", plugin.Signature) - } - - if !s.requireSigned { - return nil - } - - switch plugin.Signature { - case plugins.PluginSignatureUnsigned: - if allowed := s.allowUnsigned(plugin); !allowed { - s.log.Debug("Plugin is unsigned", "pluginID", plugin.Id) - s.errors = append(s.errors, fmt.Errorf("plugin '%s' is unsigned", plugin.Id)) - return &plugins.PluginError{ - ErrorCode: signatureMissing, + 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", "err", err) + return stream.Close() } - s.log.Warn("Running an unsigned plugin", "pluginID", plugin.Id, "pluginDir", - plugin.PluginDir) - return nil - case plugins.PluginSignatureInvalid: - s.log.Debug("Plugin has an invalid signature", "pluginID", plugin.Id) - s.errors = append(s.errors, fmt.Errorf("plugin '%s' has an invalid signature", plugin.Id)) - return &plugins.PluginError{ - ErrorCode: signatureInvalid, + + // 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) } - case plugins.PluginSignatureModified: - s.log.Debug("Plugin has a modified signature", "pluginID", plugin.Id) - s.errors = append(s.errors, fmt.Errorf("plugin '%s' has a modified signature", plugin.Id)) - return &plugins.PluginError{ - ErrorCode: signatureModified, + + if _, err := w.Write(resp.Body); err != nil { + plugin.Logger().Error("Failed to write resource response", "err", err) } - default: - panic(fmt.Sprintf("Plugin '%s' has an unrecognized plugin signature state '%s'", plugin.Id, plugin.Signature)) + + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + processedStreams++ } } -func (s *PluginScanner) allowUnsigned(plugin *plugins.PluginBase) bool { - if s.allowUnsignedPluginsCondition != nil { - return s.allowUnsignedPluginsCondition(plugin) +func (m *PluginManager) CollectMetrics(ctx context.Context, pluginID string) (*backend.CollectMetricsResult, error) { + p := m.Plugin(pluginID) + if p == nil { + return nil, backendplugin.ErrPluginNotRegistered } - 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 := pm.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) + var resp *backend.CollectMetricsResult + err := instrumentation.InstrumentCollectMetrics(p.PluginID(), func() (innerErr error) { + resp, innerErr = p.CollectMetrics(ctx) + return + }) 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 + return resp, nil } -func (pm *PluginManager) StaticRoutes() []*plugins.PluginStaticRoute { - return pm.staticRoutes +func (m *PluginManager) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + var dsURL string + if req.PluginContext.DataSourceInstanceSettings != nil { + dsURL = req.PluginContext.DataSourceInstanceSettings.URL + } + + err := m.requestValidator.Validate(dsURL, nil) + if err != nil { + return &backend.CheckHealthResult{ + Status: http.StatusForbidden, + Message: "Access denied", + }, nil + } + + p := m.Plugin(req.PluginContext.PluginID) + if p == nil { + return nil, backendplugin.ErrPluginNotRegistered + } + + var resp *backend.CheckHealthResult + err = instrumentation.InstrumentCheckHealthRequest(p.PluginID(), func() (innerErr error) { + resp, innerErr = p.CheckHealth(ctx, &backend.CheckHealthRequest{PluginContext: req.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 (pm *PluginManager) Install(ctx context.Context, pluginID, version string) error { - plugin := pm.GetPlugin(pluginID) +func (m *PluginManager) isRegistered(pluginID string) bool { + p := m.Plugin(pluginID) + if p == nil { + return false + } + return !p.IsDecommissioned() +} + +func (m *PluginManager) Add(ctx context.Context, pluginID, version string, opts plugins.AddOpts) error { var pluginZipURL string + + if opts.PluginRepoURL == "" { + opts.PluginRepoURL = grafanaComURL + } + + plugin := m.Plugin(pluginID) if plugin != nil { - if plugin.IsCorePlugin { + if !plugin.IsExternalPlugin() { return plugins.ErrInstallCorePlugin } if plugin.Info.Version == version { - return plugins.DuplicatePluginError{ - PluginID: pluginID, + return plugins.DuplicateError{ + PluginID: plugin.ID, ExistingPluginDir: plugin.PluginDir, } } // get plugin update information to confirm if upgrading is possible - updateInfo, err := pm.pluginInstaller.GetUpdateInfo(pluginID, version, grafanaComURL) + updateInfo, err := m.pluginInstaller.GetUpdateInfo(ctx, pluginID, version, opts.PluginRepoURL) if err != nil { return err } @@ -747,18 +514,26 @@ func (pm *PluginManager) Install(ctx context.Context, pluginID, version string) pluginZipURL = updateInfo.PluginZipURL // remove existing installation of plugin - err = pm.Uninstall(context.Background(), plugin.Id) + err = m.Remove(ctx, plugin.ID) if err != nil { return err } } - err := pm.pluginInstaller.Install(ctx, pluginID, version, pm.Cfg.PluginsPath, pluginZipURL, grafanaComURL) + if opts.PluginInstallDir == "" { + opts.PluginInstallDir = m.cfg.PluginsPath + } + + if opts.PluginZipURL == "" { + opts.PluginZipURL = pluginZipURL + } + + err := m.pluginInstaller.Install(ctx, pluginID, version, opts.PluginInstallDir, opts.PluginZipURL, opts.PluginRepoURL) if err != nil { return err } - err = pm.initExternalPlugins() + err = m.loadPlugins(opts.PluginInstallDir) if err != nil { return err } @@ -766,64 +541,282 @@ func (pm *PluginManager) Install(ctx context.Context, pluginID, version string) return nil } -func (pm *PluginManager) Uninstall(ctx context.Context, pluginID string) error { - plugin := pm.GetPlugin(pluginID) +func (m *PluginManager) Remove(ctx context.Context, pluginID string) error { + plugin := m.Plugin(pluginID) if plugin == nil { return plugins.ErrPluginNotInstalled } - if plugin.IsCorePlugin { + if !plugin.IsExternalPlugin() { return plugins.ErrUninstallCorePlugin } // extra security check to ensure we only remove plugins that are located in the configured plugins directory - path, err := filepath.Rel(pm.Cfg.PluginsPath, plugin.PluginDir) + path, err := filepath.Rel(m.cfg.PluginsPath, plugin.PluginDir) if err != nil || strings.HasPrefix(path, ".."+string(filepath.Separator)) { return plugins.ErrUninstallOutsideOfPluginDir } - if pm.BackendPluginManager.IsRegistered(pluginID) { - err := pm.BackendPluginManager.UnregisterAndStop(ctx, pluginID) + if m.isRegistered(pluginID) { + err := m.unregisterAndStop(ctx, plugin) if err != nil { return err } } - err = pm.unregister(plugin) + return m.pluginInstaller.Uninstall(ctx, plugin.PluginDir) +} + +func (m *PluginManager) LoadAndRegister(pluginID string, factory backendplugin.PluginFactoryFunc) error { + if m.isRegistered(pluginID) { + return fmt.Errorf("backend plugin %s already registered", pluginID) + } + + pluginRootDir := pluginID + if pluginID == "stackdriver" { + pluginRootDir = "cloud-monitoring" + } + + path := filepath.Join(m.cfg.StaticRootPath, "app/plugins/datasource", pluginRootDir) + + p, err := m.pluginLoader.LoadWithFactory(path, factory) if err != nil { return err } - return pm.pluginInstaller.Uninstall(ctx, plugin.PluginDir) -} - -func (pm *PluginManager) unregister(plugin *plugins.PluginBase) error { - pm.pluginsMu.Lock() - defer pm.pluginsMu.Unlock() - - switch plugin.Type { - case "panel": - delete(pm.panels, plugin.Id) - case "datasource": - delete(pm.dataSources, plugin.Id) - case "app": - delete(pm.apps, plugin.Id) - case "renderer": - pm.renderer = nil + err = m.register(p) + if err != nil { + return err } - delete(pm.plugins, plugin.Id) - - pm.removeStaticRoute(plugin.Id) - return nil } -func (pm *PluginManager) removeStaticRoute(pluginID string) { - for i, route := range pm.staticRoutes { - if pluginID == route.PluginId { - pm.staticRoutes = append(pm.staticRoutes[:i], pm.staticRoutes[i+1:]...) - return +func (m *PluginManager) Routes() []*plugins.StaticRoute { + staticRoutes := []*plugins.StaticRoute{} + + for _, p := range m.Plugins() { + if p.StaticRoute() != nil { + staticRoutes = append(staticRoutes, p.StaticRoute()) + } + } + return staticRoutes +} + +func (m *PluginManager) registerAndStart(ctx context.Context, plugin *plugins.Plugin) error { + err := m.register(plugin) + if err != nil { + return err + } + + if !m.isRegistered(plugin.ID) { + return fmt.Errorf("plugin %s is not registered", plugin.ID) + } + + return m.start(ctx, plugin) +} + +func (m *PluginManager) register(p *plugins.Plugin) error { + m.pluginsMu.Lock() + defer m.pluginsMu.Unlock() + + pluginID := p.ID + if _, exists := m.plugins[pluginID]; exists { + return fmt.Errorf("plugin %s already registered", pluginID) + } + + m.plugins[pluginID] = p + + if !p.IsCorePlugin() { + m.log.Info("Plugin registered", "pluginId", pluginID) + } + + return nil +} + +func (m *PluginManager) unregisterAndStop(ctx context.Context, p *plugins.Plugin) error { + m.log.Debug("Stopping plugin process", "pluginId", p.ID) + if err := p.Decommission(); err != nil { + return err + } + + if err := p.Stop(ctx); err != nil { + return err + } + + delete(m.plugins, p.ID) + + m.log.Debug("Plugin unregistered", "pluginId", p.ID) + return nil +} + +// start starts a backend plugin process +func (m *PluginManager) start(ctx context.Context, p *plugins.Plugin) error { + if !p.IsManaged() || !p.Backend || p.SignatureError != nil { + return nil + } + + if !m.isRegistered(p.ID) { + return backendplugin.ErrPluginNotRegistered + } + + if err := startPluginAndRestartKilledProcesses(ctx, p); err != nil { + return err + } + + if !p.IsCorePlugin() { + p.Logger().Debug("Successfully started backend plugin process") + } + + return nil +} + +func startPluginAndRestartKilledProcesses(ctx context.Context, p *plugins.Plugin) error { + if err := p.Start(ctx); err != nil { + return err + } + + go func(ctx context.Context, p *plugins.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 *plugins.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") } } } + +// stop stops a backend plugin process +func (m *PluginManager) 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() +} + +// corePluginPaths provides a list of the Core plugin paths which need to be scanned on init() +func (m *PluginManager) corePluginPaths() []string { + datasourcePaths := []string{ + filepath.Join(m.cfg.StaticRootPath, "app/plugins/datasource/alertmanager"), + filepath.Join(m.cfg.StaticRootPath, "app/plugins/datasource/dashboard"), + filepath.Join(m.cfg.StaticRootPath, "app/plugins/datasource/jaeger"), + filepath.Join(m.cfg.StaticRootPath, "app/plugins/datasource/mixed"), + filepath.Join(m.cfg.StaticRootPath, "app/plugins/datasource/zipkin"), + } + + panelsPath := filepath.Join(m.cfg.StaticRootPath, "app/plugins/panel") + + return append(datasourcePaths, panelsPath) +} + +// pluginSettingPaths provides a plugin paths defined in cfg.PluginSettings which need to be scanned on init() +func (m *PluginManager) pluginSettingPaths() []string { + var pluginSettingDirs []string + for _, settings := range m.cfg.PluginSettings { + path, exists := settings["path"] + if !exists || path == "" { + continue + } + pluginSettingDirs = append(pluginSettingDirs, path) + } + + return pluginSettingDirs +} + +// callResourceClientResponseStream is used for receiving resource call responses. +type callResourceClientResponseStream interface { + Recv() (*backend.CallResourceResponse, error) + Close() error +} + +type keepCookiesJSONModel struct { + KeepCookies []string `json:"keepCookies"` +} + +type callResourceResponseStream struct { + ctx context.Context + stream chan *backend.CallResourceResponse + closed bool +} + +func newCallResourceResponseStream(ctx context.Context) *callResourceResponseStream { + return &callResourceResponseStream{ + ctx: ctx, + stream: make(chan *backend.CallResourceResponse), + } +} + +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 +} diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go new file mode 100644 index 00000000000..760b9471ecf --- /dev/null +++ b/pkg/plugins/manager/manager_integration_test.go @@ -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) +} diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index 32121dd6877..9b23d9eb7a9 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -1,712 +1,482 @@ package manager import ( + "bytes" "context" - "errors" - "fmt" + "net/http" + "net/http/httptest" + "os" "path/filepath" - "reflect" - "strings" + "sync" "testing" - - "github.com/google/go-cmp/cmp" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana/pkg/models" + + "github.com/grafana/grafana/pkg/infra/fs" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" ) -const defaultAppURL = "http://localhost:3000/" +const ( + testPluginID = "test-plugin" +) -func TestPluginManager_Init(t *testing.T) { - t.Run("Base case (core + bundled plugins)", func(t *testing.T) { - staticRootPath, err := filepath.Abs("../../../public") - require.NoError(t, err) - bundledPluginsPath, err := filepath.Abs("../../../plugins-bundled/internal") +func TestPluginManager_init(t *testing.T) { + t.Run("Plugin folder will be created if not exists", func(t *testing.T) { + testDir := "plugin-test-dir" + + exists, err := fs.Exists(testDir) require.NoError(t, err) + assert.False(t, exists) pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "" - pm.Cfg.BundledPluginsPath = bundledPluginsPath - pm.Cfg.StaticRootPath = staticRootPath + pm.cfg.PluginsPath = testDir }) + err = pm.init() require.NoError(t, err) - assert.Empty(t, pm.scanningErrors) - verifyCorePluginCatalogue(t, pm) - verifyBundledPlugins(t, pm) - }) - - t.Run("Base case with single external plugin", func(t *testing.T) { - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginSettings = setting.PluginSettings{ - "nginx-app": map[string]string{ - "path": "testdata/test-app", - }, - } - }) - err := pm.init() + exists, err = fs.Exists(testDir) require.NoError(t, err) + assert.True(t, exists) - assert.Empty(t, pm.scanningErrors) - verifyCorePluginCatalogue(t, pm) - - assert.NotEmpty(t, pm.apps) - assert.Equal(t, "app/plugins/datasource/graphite/module", pm.dataSources["graphite"].Module) - assert.Equal(t, "public/plugins/test-app/img/logo_large.png", pm.apps["test-app"].Info.Logos.Large) - assert.Equal(t, "public/plugins/test-app/img/screenshot2.png", pm.apps["test-app"].Info.Screenshots[1].Path) - }) - - t.Run("With external back-end plugin lacking signature (production)", func(t *testing.T) { - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/unsigned-datasource" - pm.Cfg.Env = setting.Prod - }) - err := pm.init() - require.NoError(t, err) - const pluginID = "test" - - assert.Equal(t, []error{fmt.Errorf(`plugin '%s' is unsigned`, pluginID)}, pm.scanningErrors) - assert.Nil(t, pm.GetDataSource(pluginID)) - assert.Nil(t, pm.GetPlugin(pluginID)) - }) - - t.Run("With external back-end plugin lacking signature (development)", func(t *testing.T) { - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/unsigned-datasource" - pm.Cfg.Env = setting.Dev - }) - err := pm.init() - require.NoError(t, err) - const pluginID = "test" - - assert.Empty(t, pm.scanningErrors) - assert.NotNil(t, pm.GetDataSource(pluginID)) - - plugin := pm.GetPlugin(pluginID) - assert.NotNil(t, plugin) - assert.Equal(t, plugins.PluginSignatureUnsigned, plugin.Signature) - }) - - t.Run("With external panel plugin lacking signature (production)", func(t *testing.T) { - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/unsigned-panel" - pm.Cfg.Env = setting.Prod - }) - err := pm.init() - require.NoError(t, err) - const pluginID = "test-panel" - - assert.Equal(t, []error{fmt.Errorf(`plugin '%s' is unsigned`, pluginID)}, pm.scanningErrors) - assert.Nil(t, pm.panels[pluginID]) - assert.Nil(t, pm.GetPlugin(pluginID)) - }) - - t.Run("With external panel plugin lacking signature (development)", func(t *testing.T) { - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/unsigned-panel" - pm.Cfg.Env = setting.Dev - }) - err := pm.init() - require.NoError(t, err) - pluginID := "test-panel" - - assert.Empty(t, pm.scanningErrors) - assert.NotNil(t, pm.panels[pluginID]) - - plugin := pm.GetPlugin(pluginID) - assert.NotNil(t, plugin) - assert.Equal(t, plugins.PluginSignatureUnsigned, plugin.Signature) - }) - - t.Run("With external unsigned back-end plugin and configuration disabling signature check of this plugin", func(t *testing.T) { - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/unsigned-datasource" - pm.Cfg.PluginsAllowUnsigned = []string{"test"} - }) - 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 := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/invalid-v1-signature" - }) - err := pm.init() - require.NoError(t, err) - - const pluginID = "test" - assert.Equal(t, []error{fmt.Errorf(`plugin '%s' has an invalid signature`, pluginID)}, pm.scanningErrors) - assert.Nil(t, pm.GetDataSource(pluginID)) - assert.Nil(t, pm.GetPlugin(pluginID)) - }) - - t.Run("With external back-end plugin lacking files listed in manifest", func(t *testing.T) { - fm := &fakeBackendPluginManager{} - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/lacking-files" - pm.BackendPluginManager = fm - }) - err := pm.init() - require.NoError(t, err) - - assert.Equal(t, []error{fmt.Errorf(`plugin 'test' has a modified signature`)}, pm.scanningErrors) - }) - - t.Run("Transform plugins should be ignored when expressions feature is off", func(t *testing.T) { - fm := fakeBackendPluginManager{} - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/behind-feature-flag" - pm.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 := createManager(t, func(pm *PluginManager) { - pm.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) { - const pluginsDir = "testdata/valid-v2-signature" - const pluginFolder = pluginsDir + "/plugin" - pm := createManager(t, func(manager *PluginManager) { - manager.Cfg.PluginsPath = pluginsDir - }) - err := pm.init() - require.NoError(t, err) - require.Empty(t, pm.scanningErrors) - - // capture manager plugin state - datasources := pm.dataSources - panels := pm.panels - apps := pm.apps - - verifyPluginManagerState := func() { - assert.Empty(t, pm.scanningErrors) - verifyCorePluginCatalogue(t, pm) - - // verify plugin has been loaded successfully - const pluginID = "test" - - if diff := cmp.Diff(&plugins.PluginBase{ - Type: "datasource", - Name: "Test", - State: "alpha", - Id: pluginID, - Info: plugins.PluginInfo{ - Author: plugins.PluginInfoLink{ - Name: "Will Browne", - Url: "https://willbrowne.com", - }, - Description: "Test", - Logos: plugins.PluginLogos{ - Small: "public/img/icn-datasource.svg", - Large: "public/img/icn-datasource.svg", - }, - Build: plugins.PluginBuildInfo{}, - Version: "1.0.0", - }, - PluginDir: pluginFolder, - Backend: false, - IsCorePlugin: false, - Signature: plugins.PluginSignatureValid, - SignatureType: plugins.GrafanaType, - SignatureOrg: "Grafana Labs", - SignedFiles: plugins.PluginFiles{"plugin.json": {}}, - Dependencies: plugins.PluginDependencies{ - GrafanaVersion: "*", - Plugins: []plugins.PluginDependencyItem{}, - }, - Module: "plugins/test/module", - BaseUrl: "public/plugins/test", - }, pm.plugins[pluginID]); diff != "" { - t.Errorf("result mismatch (-want +got) %s\n", diff) - } - - ds := pm.GetDataSource(pluginID) - assert.NotNil(t, ds) - assert.Equal(t, pluginID, ds.Id) - assert.Equal(t, pm.plugins[pluginID], &ds.FrontendPluginBase.PluginBase) - - assert.Len(t, pm.StaticRoutes(), 1) - assert.Equal(t, pluginID, pm.StaticRoutes()[0].PluginId) - assert.Equal(t, pluginFolder, pm.StaticRoutes()[0].Directory) - } - - verifyPluginManagerState() - - t.Run("Re-initializing external plugins is idempotent", func(t *testing.T) { - err = pm.initExternalPlugins() + t.Cleanup(func() { + err = os.Remove(testDir) require.NoError(t, err) - - // verify plugin state remains the same as previous - verifyPluginManagerState() - - assert.Empty(t, pm.scanningErrors) - assert.True(t, reflect.DeepEqual(datasources, pm.dataSources)) - assert.True(t, reflect.DeepEqual(panels, pm.panels)) - assert.True(t, reflect.DeepEqual(apps, pm.apps)) }) }) - - 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 := createManager(t, func(pm *PluginManager) { - pm.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, pm.plugins[("test")]) - }) - - t.Run("With back-end plugin with valid v2 private signature (plugin root URL ignores trailing slash)", func(t *testing.T) { - origAppURL := setting.AppUrl - origAppSubURL := setting.AppSubUrl - t.Cleanup(func() { - setting.AppUrl = origAppURL - setting.AppSubUrl = origAppSubURL - }) - setting.AppUrl = defaultAppURL - setting.AppSubUrl = "/grafana" - - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/valid-v2-pvt-signature-root-url-uri" - }) - err := pm.init() - require.NoError(t, err) - require.Empty(t, pm.scanningErrors) - - const pluginID = "test" - assert.NotNil(t, pm.plugins[pluginID]) - assert.Equal(t, "datasource", pm.plugins[pluginID].Type) - assert.Equal(t, "Test", pm.plugins[pluginID].Name) - assert.Equal(t, pluginID, pm.plugins[pluginID].Id) - assert.Equal(t, "1.0.0", pm.plugins[pluginID].Info.Version) - assert.Equal(t, plugins.PluginSignatureValid, pm.plugins[pluginID].Signature) - assert.Equal(t, plugins.PrivateType, pm.plugins[pluginID].SignatureType) - assert.Equal(t, "Will Browne", pm.plugins[pluginID].SignatureOrg) - assert.False(t, pm.plugins[pluginID].IsCorePlugin) - }) - - 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 = defaultAppURL - - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/valid-v2-pvt-signature" - }) - err := pm.init() - require.NoError(t, err) - require.Empty(t, pm.scanningErrors) - - const pluginID = "test" - assert.NotNil(t, pm.plugins[pluginID]) - assert.Equal(t, "datasource", pm.plugins[pluginID].Type) - assert.Equal(t, "Test", pm.plugins[pluginID].Name) - assert.Equal(t, pluginID, pm.plugins[pluginID].Id) - assert.Equal(t, "1.0.0", pm.plugins[pluginID].Info.Version) - assert.Equal(t, plugins.PluginSignatureValid, pm.plugins[pluginID].Signature) - assert.Equal(t, plugins.PrivateType, pm.plugins[pluginID].SignatureType) - assert.Equal(t, "Will Browne", pm.plugins[pluginID].SignatureOrg) - assert.False(t, pm.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 = defaultAppURL - - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/invalid-v2-signature" - }) - err := pm.init() - require.NoError(t, err) - assert.Equal(t, []error{fmt.Errorf(`plugin 'test' has a modified signature`)}, pm.scanningErrors) - assert.Nil(t, pm.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 = defaultAppURL - - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/invalid-v2-signature-2" - }) - err := pm.init() - require.NoError(t, err) - assert.Equal(t, []error{fmt.Errorf(`plugin 'test' has a modified signature`)}, pm.scanningErrors) - assert.Nil(t, pm.plugins[("test")]) - }) - - t.Run("With plugin that contains symlink file + directory", func(t *testing.T) { - origAppURL := setting.AppUrl - t.Cleanup(func() { - setting.AppUrl = origAppURL - }) - setting.AppUrl = defaultAppURL - - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/includes-symlinks" - }) - err := pm.init() - require.NoError(t, err) - require.Empty(t, pm.scanningErrors) - - const pluginID = "test-app" - p := pm.GetPlugin(pluginID) - - assert.NotNil(t, p) - assert.NotNil(t, pm.GetApp(pluginID)) - assert.Equal(t, pluginID, p.Id) - assert.Equal(t, "app", p.Type) - assert.Equal(t, "Test App", p.Name) - assert.Equal(t, "1.0.0", p.Info.Version) - assert.Equal(t, plugins.PluginSignatureValid, p.Signature) - assert.Equal(t, plugins.GrafanaType, p.SignatureType) - assert.Equal(t, "Grafana Labs", p.SignatureOrg) - assert.False(t, p.IsCorePlugin) - }) - - t.Run("With back-end plugin that is symlinked to plugins dir", func(t *testing.T) { - origAppURL := setting.AppUrl - t.Cleanup(func() { - setting.AppUrl = origAppURL - }) - setting.AppUrl = defaultAppURL - - pm := createManager(t, func(pm *PluginManager) { - pm.Cfg.PluginsPath = "testdata/symbolic-plugin-dirs" - }) - err := pm.init() - require.NoError(t, err) - // This plugin should be properly registered, even though it is symlinked to plugins dir - require.Empty(t, pm.scanningErrors) - const pluginID = "test-app" - assert.NotNil(t, pm.plugins[pluginID]) - }) } -func TestPluginManager_IsBackendOnlyPlugin(t *testing.T) { - pluginScanner := &PluginScanner{} +func TestPluginManager_loadPlugins(t *testing.T) { + t.Run("Managed backend plugin", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "", plugins.External, true, true) - type testCase struct { - name string - isBackendOnly bool - } + loader := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } - 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) + pm := createManager(t, func(pm *PluginManager) { + pm.pluginLoader = loader }) - } + err := pm.loadPlugins("test/path") + require.NoError(t, err) + + assert.Equal(t, 1, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + + verifyNoPluginErrors(t, pm) + }) + + t.Run("Unmanaged backend plugin", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "", plugins.External, false, true) + + loader := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } + + pm := createManager(t, func(pm *PluginManager) { + pm.pluginLoader = loader + }) + err := pm.loadPlugins("test/path") + require.NoError(t, err) + + assert.Equal(t, 0, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + + verifyNoPluginErrors(t, pm) + }) + + t.Run("Managed non-backend plugin", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "", plugins.External, false, true) + + loader := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } + + pm := createManager(t, func(pm *PluginManager) { + pm.pluginLoader = loader + }) + err := pm.loadPlugins("test/path") + require.NoError(t, err) + + assert.Equal(t, 0, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + + verifyNoPluginErrors(t, pm) + }) + + t.Run("Unmanaged non-backend plugin", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "", plugins.External, false, false) + + loader := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } + + pm := createManager(t, func(pm *PluginManager) { + pm.pluginLoader = loader + }) + err := pm.loadPlugins("test/path") + require.NoError(t, err) + + assert.Equal(t, 0, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + + verifyNoPluginErrors(t, pm) + }) } func TestPluginManager_Installer(t *testing.T) { - t.Run("Install plugin after manager init", func(t *testing.T) { - fm := &fakeBackendPluginManager{} - pm := createManager(t, func(pm *PluginManager) { - pm.BackendPluginManager = fm - }) + t.Run("Install", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "1.0.0", plugins.External, true, true) - err := pm.init() - require.NoError(t, err) - - // mock installer - installer := &fakePluginInstaller{} - pm.pluginInstaller = installer - - // Set plugin location (we do this after manager Init() so that - // it doesn't install the plugin automatically) - pm.Cfg.PluginsPath = "testdata/installer" - - pluginID := "test" - pluginFolder := pm.Cfg.PluginsPath + "/plugin" - - err = pm.Install(context.Background(), pluginID, "1.0.0") - require.NoError(t, err) - - assert.Equal(t, 1, installer.installCount) - assert.Equal(t, 0, installer.uninstallCount) - - // verify plugin manager has loaded core plugins successfully - assert.Empty(t, pm.scanningErrors) - verifyCorePluginCatalogue(t, pm) - - // verify plugin has been loaded successfully - assert.NotNil(t, pm.plugins[pluginID]) - if diff := cmp.Diff(&plugins.PluginBase{ - Type: "datasource", - Name: "Test", - State: "alpha", - Id: pluginID, - Info: plugins.PluginInfo{ - Author: plugins.PluginInfoLink{ - Name: "Will Browne", - Url: "https://willbrowne.com", - }, - Description: "Test", - Logos: plugins.PluginLogos{ - Small: "public/img/icn-datasource.svg", - Large: "public/img/icn-datasource.svg", - }, - Build: plugins.PluginBuildInfo{}, - Version: "1.0.0", - }, - PluginDir: pluginFolder, - Backend: false, - IsCorePlugin: false, - Signature: plugins.PluginSignatureValid, - SignatureType: plugins.GrafanaType, - SignatureOrg: "Grafana Labs", - SignedFiles: plugins.PluginFiles{"plugin.json": {}}, - Dependencies: plugins.PluginDependencies{ - GrafanaVersion: "*", - Plugins: []plugins.PluginDependencyItem{}, - }, - Module: "plugins/test/module", - BaseUrl: "public/plugins/test", - }, pm.plugins[pluginID]); diff != "" { - t.Errorf("result mismatch (-want +got) %s\n", diff) + l := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, } - ds := pm.GetDataSource(pluginID) - assert.NotNil(t, ds) - assert.Equal(t, pluginID, ds.Id) - assert.Equal(t, pm.plugins[pluginID], &ds.FrontendPluginBase.PluginBase) + i := &fakePluginInstaller{} + pm := createManager(t, func(pm *PluginManager) { + pm.pluginInstaller = i + pm.pluginLoader = l + }) - assert.Len(t, pm.StaticRoutes(), 1) - assert.Equal(t, pluginID, pm.StaticRoutes()[0].PluginId) - assert.Equal(t, pluginFolder, pm.StaticRoutes()[0].Directory) + err := pm.Add(context.Background(), testPluginID, "1.0.0", plugins.AddOpts{}) + require.NoError(t, err) + + assert.Equal(t, 1, i.installCount) + assert.Equal(t, 0, i.uninstallCount) + + verifyNoPluginErrors(t, pm) + + assert.Len(t, pm.Routes(), 1) + assert.Equal(t, p.ID, pm.Routes()[0].PluginID) + assert.Equal(t, p.PluginDir, pm.Routes()[0].Directory) + + assert.Equal(t, 1, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) t.Run("Won't install if already installed", func(t *testing.T) { - err := pm.Install(context.Background(), pluginID, "1.0.0") - require.Equal(t, plugins.DuplicatePluginError{ - PluginID: pluginID, - ExistingPluginDir: pluginFolder, + err := pm.Add(context.Background(), testPluginID, "1.0.0", plugins.AddOpts{}) + assert.Equal(t, plugins.DuplicateError{ + PluginID: p.ID, + ExistingPluginDir: p.PluginDir, }, err) }) - t.Run("Uninstall base case", func(t *testing.T) { - err := pm.Uninstall(context.Background(), pluginID) + t.Run("Update", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "1.2.0", plugins.External, true, true) + + l := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } + pm.pluginLoader = l + + err = pm.Add(context.Background(), testPluginID, "1.2.0", plugins.AddOpts{}) + assert.NoError(t, err) + + assert.Equal(t, 2, i.installCount) + assert.Equal(t, 1, i.uninstallCount) + + assert.Equal(t, 1, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + }) + + t.Run("Uninstall", func(t *testing.T) { + err := pm.Remove(context.Background(), p.ID) require.NoError(t, err) - assert.Equal(t, 1, installer.installCount) - assert.Equal(t, 1, installer.uninstallCount) + assert.Equal(t, 2, i.installCount) + assert.Equal(t, 2, i.uninstallCount) - assert.Nil(t, pm.GetDataSource(pluginID)) - assert.Nil(t, pm.GetPlugin(pluginID)) - assert.Len(t, pm.StaticRoutes(), 0) + assert.Nil(t, pm.Plugin(p.ID)) + assert.Len(t, pm.Routes(), 0) t.Run("Won't uninstall if not installed", func(t *testing.T) { - err := pm.Uninstall(context.Background(), pluginID) + err := pm.Remove(context.Background(), p.ID) require.Equal(t, plugins.ErrPluginNotInstalled, err) }) }) }) + + t.Run("Can't update core plugin", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "", plugins.Core, true, true) + + loader := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } + + pm := createManager(t, func(pm *PluginManager) { + pm.pluginLoader = loader + }) + err := pm.loadPlugins("test/path") + require.NoError(t, err) + + assert.Equal(t, 1, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + + verifyNoPluginErrors(t, pm) + + err = pm.Add(context.Background(), testPluginID, "", plugins.AddOpts{}) + assert.Equal(t, plugins.ErrInstallCorePlugin, err) + + t.Run("Can't uninstall core plugin", func(t *testing.T) { + err := pm.Remove(context.Background(), p.ID) + require.Equal(t, plugins.ErrUninstallCorePlugin, err) + }) + }) + + t.Run("Can't update bundled plugin", func(t *testing.T) { + p, pc := createPlugin(testPluginID, "", plugins.Bundled, true, true) + + loader := &fakeLoader{ + mockedLoadedPlugins: []*plugins.Plugin{p}, + } + + pm := createManager(t, func(pm *PluginManager) { + pm.pluginLoader = loader + }) + err := pm.loadPlugins("test/path") + require.NoError(t, err) + + assert.Equal(t, 1, pc.startCount) + assert.Equal(t, 0, pc.stopCount) + assert.False(t, pc.exited) + assert.False(t, pc.decommissioned) + assert.Equal(t, p, pm.Plugin(testPluginID)) + assert.Len(t, pm.Plugins(), 1) + + verifyNoPluginErrors(t, pm) + + err = pm.Add(context.Background(), testPluginID, "", plugins.AddOpts{}) + assert.Equal(t, plugins.ErrInstallCorePlugin, err) + + t.Run("Can't uninstall bundled plugin", func(t *testing.T) { + err := pm.Remove(context.Background(), p.ID) + require.Equal(t, plugins.ErrUninstallCorePlugin, err) + }) + }) +} + +func TestPluginManager_lifecycle_managed(t *testing.T) { + newScenario(t, true, func(t *testing.T, ctx *managerScenarioCtx) { + t.Run("Managed plugin scenario", func(t *testing.T) { + t.Run("Should be able to register plugin", func(t *testing.T) { + err := ctx.manager.registerAndStart(context.Background(), ctx.plugin) + require.NoError(t, err) + require.NotNil(t, ctx.plugin) + require.Equal(t, testPluginID, ctx.plugin.ID) + require.Equal(t, 1, ctx.pluginClient.startCount) + require.NotNil(t, ctx.manager.Plugin(testPluginID)) + + t.Run("Should not be able to register an already registered plugin", func(t *testing.T) { + err := ctx.manager.registerAndStart(context.Background(), ctx.plugin) + require.Equal(t, 1, ctx.pluginClient.startCount) + require.Error(t, err) + }) + + 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.pluginClient.startCount) + require.Equal(t, 1, ctx.pluginClient.stopCount) + }) + + t.Run("When manager runs should restart plugin process when killed", func(t *testing.T) { + ctx.pluginClient.stopCount = 0 + ctx.pluginClient.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.pluginClient.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.pluginClient.stopCount) + require.Equal(t, 1, ctx.pluginClient.startCount) + }) + + 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.CheckHealthRequest{PluginContext: 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.pluginClient.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.pluginClient.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.CheckHealthRequest{PluginContext: 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.pluginClient.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) + }) + }) + }) + }) + }) } -func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) { - t.Helper() +func TestPluginManager_lifecycle_unmanaged(t *testing.T) { + newScenario(t, false, func(t *testing.T, ctx *managerScenarioCtx) { + t.Run("Unmanaged plugin scenario", func(t *testing.T) { + t.Run("Should be able to register plugin", func(t *testing.T) { + err := ctx.manager.registerAndStart(context.Background(), ctx.plugin) + require.NoError(t, err) + require.True(t, ctx.manager.isRegistered(testPluginID)) + require.False(t, ctx.pluginClient.managed) - panels := []string{ - "alertlist", - "annolist", - "barchart", - "bargauge", - "dashlist", - "debug", - "gauge", - "gettingstarted", - "graph", - "heatmap", - "live", - "logs", - "news", - "nodeGraph", - "piechart", - "pluginlist", - "stat", - "table", - "table-old", - "text", - "state-timeline", - "status-history", - "timeseries", - "welcome", - "xychart", - } + 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.pluginClient.startCount) + require.Equal(t, 1, ctx.pluginClient.stopCount) + require.True(t, ctx.plugin.Exited()) + }) - datasources := []string{ - "alertmanager", - "stackdriver", - "cloudwatch", - "dashboard", - "elasticsearch", - "grafana", - "grafana-azure-monitor-datasource", - "graphite", - "influxdb", - "jaeger", - "loki", - "mixed", - "mssql", - "mysql", - "opentsdb", - "postgres", - "prometheus", - "tempo", - "testdata", - "zipkin", - } - - for _, p := range panels { - assert.NotNil(t, pm.plugins[p]) - assert.NotNil(t, pm.panels[p]) - } - - for _, ds := range datasources { - assert.NotNil(t, pm.plugins[ds]) - assert.NotNil(t, pm.dataSources[ds]) - } -} - -func verifyBundledPlugins(t *testing.T, pm *PluginManager) { - t.Helper() - - bundledPlugins := map[string]string{ - "input": "input-datasource", - } - - for pluginID, pluginDir := range bundledPlugins { - assert.NotNil(t, pm.plugins[pluginID]) - for _, route := range pm.staticRoutes { - if pluginID == route.PluginId { - assert.True(t, strings.HasPrefix(route.Directory, pm.Cfg.BundledPluginsPath+"/"+pluginDir)) - } - } - } - - assert.NotNil(t, pm.dataSources["input"]) -} - -type fakeBackendPluginManager struct { - registeredPlugins []string -} - -func (f *fakeBackendPluginManager) Register(pluginID string, factory backendplugin.PluginFactoryFunc) error { - f.registeredPlugins = append(f.registeredPlugins, pluginID) - return nil -} - -func (f *fakeBackendPluginManager) RegisterAndStart(ctx context.Context, pluginID string, factory backendplugin.PluginFactoryFunc) error { - f.registeredPlugins = append(f.registeredPlugins, pluginID) - return nil -} - -func (f *fakeBackendPluginManager) Get(pluginID string) (backendplugin.Plugin, bool) { - return nil, false -} - -func (f *fakeBackendPluginManager) UnregisterAndStop(ctx context.Context, pluginID string) error { - var result []string - - for _, existingPlugin := range f.registeredPlugins { - if pluginID != existingPlugin { - result = append(result, pluginID) - } - } - - f.registeredPlugins = result - return nil -} - -func (f *fakeBackendPluginManager) IsRegistered(pluginID string) bool { - for _, existingPlugin := range f.registeredPlugins { - if pluginID == existingPlugin { - return true - } - } - return false -} - -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) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - return nil, nil -} - -func (f *fakeBackendPluginManager) CallResource(pluginConfig backend.PluginContext, ctx *models.ReqContext, path string) { -} - -var _ backendplugin.Manager = &fakeBackendPluginManager{} - -type fakePluginInstaller struct { - installCount int - uninstallCount int -} - -func (f *fakePluginInstaller) Install(ctx context.Context, pluginID, version, pluginsDirectory, pluginZipURL, pluginRepoURL string) error { - f.installCount++ - return nil -} - -func (f *fakePluginInstaller) Uninstall(ctx context.Context, pluginPath string) error { - f.uninstallCount++ - return nil -} - -func (f *fakePluginInstaller) GetUpdateInfo(pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error) { - return plugins.UpdateInfo{}, nil + t.Run("Should be not be able to start unmanaged plugin", func(t *testing.T) { + pCtx := context.Background() + cCtx, cancel := context.WithCancel(pCtx) + defer cancel() + err := ctx.manager.start(cCtx, ctx.plugin) + require.Nil(t, err) + require.Equal(t, 0, ctx.pluginClient.startCount) + require.True(t, ctx.plugin.Exited()) + }) + }) + }) + }) } func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager { @@ -720,7 +490,10 @@ func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager { Env: setting.Prod, StaticRootPath: staticRootPath, } - pm := newManager(cfg, &sqlstore.SQLStore{}, &fakeBackendPluginManager{}) + + requestValidator := &testPluginRequestValidator{} + loader := &fakeLoader{} + pm := newManager(cfg, requestValidator, loader, &sqlstore.SQLStore{}) for _, cb := range cbs { cb(pm) @@ -728,3 +501,246 @@ func createManager(t *testing.T, cbs ...func(*PluginManager)) *PluginManager { return pm } + +func createPlugin(pluginID, version string, class plugins.Class, managed, backend bool) (*plugins.Plugin, *fakePluginClient) { + p := &plugins.Plugin{ + Class: class, + JSONData: plugins.JSONData{ + ID: pluginID, + Type: plugins.DataSource, + Backend: backend, + Info: plugins.Info{ + Version: version, + }, + }, + } + + logger := fakeLogger{} + + p.SetLogger(logger) + + pc := &fakePluginClient{ + pluginID: pluginID, + logger: logger, + managed: managed, + } + + p.RegisterClient(pc) + + return p, pc +} + +type managerScenarioCtx struct { + manager *PluginManager + plugin *plugins.Plugin + pluginClient *fakePluginClient +} + +func newScenario(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" + + staticRootPath, err := filepath.Abs("../../../public") + require.NoError(t, err) + cfg.StaticRootPath = staticRootPath + + requestValidator := &testPluginRequestValidator{} + loader := &fakeLoader{} + manager := newManager(cfg, requestValidator, loader, nil) + manager.pluginLoader = loader + ctx := &managerScenarioCtx{ + manager: manager, + } + + ctx.plugin, ctx.pluginClient = createPlugin(testPluginID, "", plugins.Core, managed, true) + + fn(t, ctx) +} + +func verifyNoPluginErrors(t *testing.T, pm *PluginManager) { + for _, plugin := range pm.Plugins() { + assert.Nil(t, plugin.SignatureError) + } +} + +type fakePluginInstaller struct { + plugins.Installer + + installCount int + uninstallCount int +} + +func (f *fakePluginInstaller) Install(ctx context.Context, pluginID, version, pluginsDir, pluginZipURL, pluginRepoURL string) error { + f.installCount++ + return nil +} + +func (f *fakePluginInstaller) Uninstall(ctx context.Context, pluginPath string) error { + f.uninstallCount++ + return nil +} + +func (f *fakePluginInstaller) GetUpdateInfo(ctx context.Context, pluginID, version, pluginRepoURL string) (plugins.UpdateInfo, error) { + return plugins.UpdateInfo{}, nil +} + +type fakeLoader struct { + mockedLoadedPlugins []*plugins.Plugin + mockedFactoryLoadedPlugin *plugins.Plugin + + loadedPaths []string + + plugins.Loader +} + +func (l *fakeLoader) Load(paths []string, ignore map[string]struct{}) ([]*plugins.Plugin, error) { + l.loadedPaths = append(l.loadedPaths, paths...) + + return l.mockedLoadedPlugins, nil +} + +func (l *fakeLoader) LoadWithFactory(path string, factory backendplugin.PluginFactoryFunc) (*plugins.Plugin, error) { + l.loadedPaths = append(l.loadedPaths, path) + + return l.mockedFactoryLoadedPlugin, nil +} + +type fakePluginClient 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 + + backendplugin.Plugin +} + +func (tp *fakePluginClient) PluginID() string { + return tp.pluginID +} + +func (tp *fakePluginClient) Logger() log.Logger { + return tp.logger +} + +func (tp *fakePluginClient) Start(ctx context.Context) error { + tp.mutex.Lock() + defer tp.mutex.Unlock() + tp.exited = false + tp.startCount++ + return nil +} + +func (tp *fakePluginClient) Stop(ctx context.Context) error { + tp.mutex.Lock() + defer tp.mutex.Unlock() + tp.stopCount++ + tp.exited = true + return nil +} + +func (tp *fakePluginClient) IsManaged() bool { + return tp.managed +} + +func (tp *fakePluginClient) Exited() bool { + tp.mutex.RLock() + defer tp.mutex.RUnlock() + return tp.exited +} + +func (tp *fakePluginClient) Decommission() error { + tp.mutex.Lock() + defer tp.mutex.Unlock() + + tp.decommissioned = true + + return nil +} + +func (tp *fakePluginClient) IsDecommissioned() bool { + tp.mutex.RLock() + defer tp.mutex.RUnlock() + return tp.decommissioned +} + +func (tp *fakePluginClient) kill() { + tp.mutex.Lock() + defer tp.mutex.Unlock() + tp.exited = true +} + +func (tp *fakePluginClient) CollectMetrics(ctx context.Context) (*backend.CollectMetricsResult, error) { + if tp.CollectMetricsHandlerFunc != nil { + return tp.CollectMetricsHandlerFunc(ctx) + } + + return nil, backendplugin.ErrMethodNotImplemented +} + +func (tp *fakePluginClient) 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 *fakePluginClient) 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 *fakePluginClient) 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 *fakePluginClient) SubscribeStream(ctx context.Context, request *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + return nil, backendplugin.ErrMethodNotImplemented +} + +func (tp *fakePluginClient) PublishStream(ctx context.Context, request *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + return nil, backendplugin.ErrMethodNotImplemented +} + +func (tp *fakePluginClient) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error { + return backendplugin.ErrMethodNotImplemented +} + +type testPluginRequestValidator struct{} + +func (t *testPluginRequestValidator) Validate(string, *http.Request) error { + return nil +} + +type fakeLogger struct { + log.Logger +} + +func (tl fakeLogger) Info(msg string, ctx ...interface{}) { + +} + +func (tl fakeLogger) Debug(msg string, ctx ...interface{}) { + +} diff --git a/pkg/plugins/manager/queries.go b/pkg/plugins/manager/queries.go deleted file mode 100644 index f47a7314e2b..00000000000 --- a/pkg/plugins/manager/queries.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/manager/signature/authorizer.go b/pkg/plugins/manager/signature/authorizer.go new file mode 100644 index 00000000000..ac4b4f19f9e --- /dev/null +++ b/pkg/plugins/manager/signature/authorizer.go @@ -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 +} diff --git a/pkg/plugins/manager/manifest.go b/pkg/plugins/manager/signature/manifest.go similarity index 72% rename from pkg/plugins/manager/manifest.go rename to pkg/plugins/manager/signature/manifest.go index 58b03558bd6..5bca21be755 100644 --- a/pkg/plugins/manager/manifest.go +++ b/pkg/plugins/manager/signature/manifest.go @@ -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 { diff --git a/pkg/plugins/manager/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go similarity index 97% rename from pkg/plugins/manager/manifest_test.go rename to pkg/plugins/manager/signature/manifest_test.go index 80e20d340fb..e5023c2c527 100644 --- a/pkg/plugins/manager/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -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) diff --git a/pkg/plugins/manager/signature/signature.go b/pkg/plugins/manager/signature/signature.go new file mode 100644 index 00000000000..92ef990cc35 --- /dev/null +++ b/pkg/plugins/manager/signature/signature.go @@ -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, + } + } +} diff --git a/pkg/plugins/manager/testdata/invalid-plugin-json/plugin.json b/pkg/plugins/manager/testdata/invalid-plugin-json/plugin.json new file mode 100644 index 00000000000..35af776b8b2 --- /dev/null +++ b/pkg/plugins/manager/testdata/invalid-plugin-json/plugin.json @@ -0,0 +1,4 @@ +{ + "id": "test-app", + "type": "application" +} diff --git a/pkg/plugins/manager/testdata/nested-plugins/parent/MANIFEST.txt b/pkg/plugins/manager/testdata/nested-plugins/parent/MANIFEST.txt new file mode 100644 index 00000000000..9a97644352c --- /dev/null +++ b/pkg/plugins/manager/testdata/nested-plugins/parent/MANIFEST.txt @@ -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----- diff --git a/pkg/plugins/manager/testdata/nested-plugins/parent/nested/plugin.json b/pkg/plugins/manager/testdata/nested-plugins/parent/nested/plugin.json new file mode 100644 index 00000000000..800f34353d0 --- /dev/null +++ b/pkg/plugins/manager/testdata/nested-plugins/parent/nested/plugin.json @@ -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" + } +} diff --git a/pkg/plugins/manager/testdata/nested-plugins/parent/plugin.json b/pkg/plugins/manager/testdata/nested-plugins/parent/plugin.json new file mode 100644 index 00000000000..47b0721754f --- /dev/null +++ b/pkg/plugins/manager/testdata/nested-plugins/parent/plugin.json @@ -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" + } +} diff --git a/pkg/plugins/manager/update_checker.go b/pkg/plugins/manager/update_checker.go index 26e21727d5c..3ba2a9daa57 100644 --- a/pkg/plugins/manager/update_checker.go +++ b/pkg/plugins/manager/update_checker.go @@ -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) - } - }() - 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 - } - - 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) - } +} + +func (m *PluginManager) externalPluginIDsAsCSV() string { + var result []string + for _, p := range m.plugins { + if p.IsCorePlugin() { + continue + } + + result = append(result, p.ID) + } + + return strings.Join(result, ",") } diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 613ee44b2a5..89526e5e1ff 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -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"` } diff --git a/pkg/plugins/panel_plugin.go b/pkg/plugins/panel_plugin.go deleted file mode 100644 index b6d1ba4720f..00000000000 --- a/pkg/plugins/panel_plugin.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/plugincontext/plugincontext.go b/pkg/plugins/plugincontext/plugincontext.go index 953e5bf2b98..4efb903820c 100644 --- a/pkg/plugins/plugincontext/plugincontext.go +++ b/pkg/plugins/plugincontext/plugincontext.go @@ -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, diff --git a/pkg/plugins/plugindashboards/service.go b/pkg/plugins/plugindashboards/service.go index 8ad8fe2070f..88f98bdeda7 100644 --- a/pkg/plugins/plugindashboards/service.go +++ b/pkg/plugins/plugindashboards/service.go @@ -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 } diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 9614d511072..57f2a913750 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -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 +} diff --git a/pkg/plugins/renderer_plugin.go b/pkg/plugins/renderer_plugin.go deleted file mode 100644 index 568473ac155..00000000000 --- a/pkg/plugins/renderer_plugin.go +++ /dev/null @@ -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 -} diff --git a/pkg/plugins/state.go b/pkg/plugins/state.go deleted file mode 100644 index 3cf2b879123..00000000000 --- a/pkg/plugins/state.go +++ /dev/null @@ -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 -} diff --git a/pkg/server/backgroundsvcs/background_services.go b/pkg/server/backgroundsvcs/background_services.go index 8e43357c349..8e7708f4f9d 100644 --- a/pkg/server/backgroundsvcs/background_services.go +++ b/pkg/server/backgroundsvcs/background_services.go @@ -7,7 +7,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" uss "github.com/grafana/grafana/pkg/infra/usagestats/service" "github.com/grafana/grafana/pkg/models" - backendmanager "github.com/grafana/grafana/pkg/plugins/backendplugin/manager" "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/plugindashboards" "github.com/grafana/grafana/pkg/registry" @@ -22,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" "github.com/grafana/grafana/pkg/tsdb/cloudmonitoring" "github.com/grafana/grafana/pkg/tsdb/cloudwatch" @@ -44,12 +44,12 @@ func ProvideBackgroundServiceRegistry( live *live.GrafanaLive, pushGateway *pushhttp.Gateway, notifications *notifications.NotificationService, rendering *rendering.RenderingService, tokenService models.UserTokenBackgroundService, provisioning *provisioning.ProvisioningServiceImpl, alerting *alerting.AlertEngine, pm *manager.PluginManager, - backendPM *backendmanager.Manager, metrics *metrics.InternalMetricsService, - usageStats *uss.UsageStats, tracing *tracing.TracingService, remoteCache *remotecache.RemoteCache, + metrics *metrics.InternalMetricsService, usageStats *uss.UsageStats, updateChecker *updatechecker.Service, + tracing *tracing.TracingService, remoteCache *remotecache.RemoteCache, // Need to make sure these are initialized, is there a better place to put them? _ *azuremonitor.Service, _ *cloudwatch.CloudWatchService, _ *elasticsearch.Service, _ *graphite.Service, _ *influxdb.Service, _ *loki.Service, _ *opentsdb.Service, _ *prometheus.Service, _ *tempo.Service, - _ *testdatasource.TestDataPlugin, _ *plugindashboards.Service, _ *dashboardsnapshots.Service, _ secrets.Service, + _ *testdatasource.Service, _ *plugindashboards.Service, _ *dashboardsnapshots.Service, _ secrets.Service, _ *postgres.Service, _ *mysql.Service, _ *mssql.Service, _ *grafanads.Service, _ *cloudmonitoring.Service, _ *pluginsettings.Service, _ *alerting.AlertNotificationService, ) *BackgroundServiceRegistry { @@ -65,7 +65,7 @@ func ProvideBackgroundServiceRegistry( provisioning, alerting, pm, - backendPM, + updateChecker, metrics, usageStats, tracing, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 12ea2d90139..7ef39447763 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -22,9 +22,8 @@ import ( "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin" - backendmanager "github.com/grafana/grafana/pkg/plugins/backendplugin/manager" "github.com/grafana/grafana/pkg/plugins/manager" + "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/plugins/plugindashboards" "github.com/grafana/grafana/pkg/services/alerting" @@ -56,6 +55,7 @@ import ( secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/shorturls" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -92,12 +92,19 @@ var wireBasicSet = wire.NewSet( hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, + updatechecker.ProvideService, uss.ProvideService, wire.Bind(new(usagestats.Service), new(*uss.UsageStats)), manager.ProvideService, - wire.Bind(new(plugins.Manager), new(*manager.PluginManager)), - backendmanager.ProvideService, - wire.Bind(new(backendplugin.Manager), new(*backendmanager.Manager)), + wire.Bind(new(plugins.Client), new(*manager.PluginManager)), + wire.Bind(new(plugins.Store), new(*manager.PluginManager)), + wire.Bind(new(plugins.CoreBackendRegistrar), new(*manager.PluginManager)), + wire.Bind(new(plugins.StaticRouteResolver), new(*manager.PluginManager)), + wire.Bind(new(plugins.PluginDashboardManager), new(*manager.PluginManager)), + wire.Bind(new(plugins.RendererManager), new(*manager.PluginManager)), + loader.ProvideService, + wire.Bind(new(plugins.Loader), new(*loader.Loader)), + wire.Bind(new(plugins.ErrorResolver), new(*loader.Loader)), cloudwatch.ProvideService, cloudwatch.ProvideLogsService, cloudmonitoring.ProvideService, diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index c811418b73c..b970d762951 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -6,6 +6,8 @@ package server import ( "github.com/google/wire" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/server/backgroundsvcs" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -54,6 +56,8 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(models.SearchUserFilter), new(*filters.OSSSearchUserFilter)), searchusers.ProvideUsersService, wire.Bind(new(searchusers.Service), new(*searchusers.OSSService)), + signature.ProvideService, + wire.Bind(new(plugins.PluginLoaderAuthorizer), new(*signature.UnsignedPluginAuthorizer)), ) var wireExtsSet = wire.NewSet( diff --git a/pkg/services/datasourceproxy/datasourceproxy.go b/pkg/services/datasourceproxy/datasourceproxy.go index 15141b369fb..cc51da693b1 100644 --- a/pkg/services/datasourceproxy/datasourceproxy.go +++ b/pkg/services/datasourceproxy/datasourceproxy.go @@ -18,12 +18,12 @@ import ( ) func ProvideService(dataSourceCache datasources.CacheService, plugReqValidator models.PluginRequestValidator, - pm plugins.Manager, cfg *setting.Cfg, httpClientProvider httpclient.Provider, + pluginStore plugins.Store, cfg *setting.Cfg, httpClientProvider httpclient.Provider, oauthTokenService *oauthtoken.Service, dsService *datasources.Service) *DataSourceProxyService { return &DataSourceProxyService{ DataSourceCache: dataSourceCache, PluginRequestValidator: plugReqValidator, - PluginManager: pm, + pluginStore: pluginStore, Cfg: cfg, HTTPClientProvider: httpClientProvider, OAuthTokenService: oauthTokenService, @@ -34,7 +34,7 @@ func ProvideService(dataSourceCache datasources.CacheService, plugReqValidator m type DataSourceProxyService struct { DataSourceCache datasources.CacheService PluginRequestValidator models.PluginRequestValidator - PluginManager plugins.Manager + pluginStore plugins.Store Cfg *setting.Cfg HTTPClientProvider httpclient.Provider OAuthTokenService *oauthtoken.Service @@ -69,16 +69,15 @@ func (p *DataSourceProxyService) ProxyDatasourceRequestWithID(c *models.ReqConte } // find plugin - plugin := p.PluginManager.GetDataSource(ds.Type) + plugin := p.pluginStore.Plugin(ds.Type) if plugin == nil { c.JsonApiErr(http.StatusNotFound, "Unable to find datasource plugin", err) return } - proxy, err := pluginproxy.NewDataSourceProxy( - ds, plugin, c, getProxyPath(c), p.Cfg, p.HTTPClientProvider, p.OAuthTokenService, p.DataSourcesService, - ) - + proxyPath := getProxyPath(c) + proxy, err := pluginproxy.NewDataSourceProxy(ds, plugin.Routes, c, proxyPath, p.Cfg, p.HTTPClientProvider, + p.OAuthTokenService, p.DataSourcesService) if err != nil { if errors.Is(err, datasource.URLValidationError{}) { c.JsonApiErr(http.StatusBadRequest, fmt.Sprintf("Invalid data source URL: %q", ds.Url), err) diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 4e56e438740..91767a8601e 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -26,7 +26,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/plugins/manager" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/plugincontext" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/live/database" @@ -61,7 +61,7 @@ type CoreGrafanaScope struct { } func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, routeRegister routing.RouteRegister, - logsService *cloudwatch.LogsService, pluginManager *manager.PluginManager, cacheService *localcache.CacheService, + logsService *cloudwatch.LogsService, pluginStore plugins.Store, cacheService *localcache.CacheService, dataSourceCache datasources.CacheService, sqlStore *sqlstore.SQLStore, usageStatsService usagestats.Service) (*GrafanaLive, error) { g := &GrafanaLive{ @@ -69,7 +69,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r PluginContextProvider: plugCtxProvider, RouteRegister: routeRegister, LogsService: logsService, - PluginManager: pluginManager, + pluginStore: pluginStore, CacheService: cacheService, DataSourceCache: dataSourceCache, SQLStore: sqlStore, @@ -361,10 +361,10 @@ type GrafanaLive struct { Cfg *setting.Cfg RouteRegister routing.RouteRegister LogsService *cloudwatch.LogsService - PluginManager *manager.PluginManager CacheService *localcache.CacheService DataSourceCache datasources.CacheService SQLStore *sqlstore.SQLStore + pluginStore plugins.Store node *centrifuge.Node surveyCaller *survey.Caller @@ -393,15 +393,14 @@ type GrafanaLive struct { } func (g *GrafanaLive) getStreamPlugin(pluginID string) (backend.StreamHandler, error) { - plugin, ok := g.PluginManager.BackendPluginManager.Get(pluginID) - if !ok { + plugin := g.pluginStore.Plugin(pluginID) + if plugin == nil { return nil, fmt.Errorf("plugin not found: %s", pluginID) } - streamHandler, ok := plugin.(backend.StreamHandler) - if !ok { - return nil, fmt.Errorf("%s plugin does not implement StreamHandler: %#v", pluginID, plugin) + if plugin.SupportsStreaming() { + return plugin, nil } - return streamHandler, nil + return nil, fmt.Errorf("%s plugin does not implement StreamHandler: %#v", pluginID, plugin) } func (g *GrafanaLive) Run(ctx context.Context) error { diff --git a/pkg/services/provisioning/plugins/config_reader.go b/pkg/services/provisioning/plugins/config_reader.go index 7931f3d5111..b707fe79614 100644 --- a/pkg/services/provisioning/plugins/config_reader.go +++ b/pkg/services/provisioning/plugins/config_reader.go @@ -17,12 +17,12 @@ type configReader interface { } type configReaderImpl struct { - log log.Logger - pluginManager plugins.Manager + log log.Logger + pluginStore plugins.Store } -func newConfigReader(logger log.Logger, pluginManager plugins.Manager) configReader { - return &configReaderImpl{log: logger, pluginManager: pluginManager} +func newConfigReader(logger log.Logger, pluginStore plugins.Store) configReader { + return &configReaderImpl{log: logger, pluginStore: pluginStore} } func (cr *configReaderImpl) readConfig(path string) ([]*pluginsAsConfig, error) { @@ -113,8 +113,8 @@ func (cr *configReaderImpl) validatePluginsConfig(apps []*pluginsAsConfig) error } for _, app := range apps[i].Apps { - if !cr.pluginManager.IsAppInstalled(app.PluginID) { - return fmt.Errorf("app plugin not installed: %q", app.PluginID) + if p := cr.pluginStore.Plugin(app.PluginID); p == nil { + return fmt.Errorf("plugin not installed: %q", app.PluginID) } } } diff --git a/pkg/services/provisioning/plugins/config_reader_test.go b/pkg/services/provisioning/plugins/config_reader_test.go index eaf6f4b0d78..a5335d697d0 100644 --- a/pkg/services/provisioning/plugins/config_reader_test.go +++ b/pkg/services/provisioning/plugins/config_reader_test.go @@ -32,10 +32,10 @@ func TestConfigReader(t *testing.T) { }) t.Run("Unknown app plugin should return error", func(t *testing.T) { - cfgProvider := newConfigReader(log.New("test logger"), fakePluginManager{}) + cfgProvider := newConfigReader(log.New("test logger"), fakePluginStore{}) _, err := cfgProvider.readConfig(unknownApp) require.Error(t, err) - require.Equal(t, "app plugin not installed: \"nonexisting\"", err.Error()) + require.Equal(t, "plugin not installed: \"nonexisting\"", err.Error()) }) t.Run("Read incorrect properties", func(t *testing.T) { @@ -46,8 +46,8 @@ func TestConfigReader(t *testing.T) { }) t.Run("Can read correct properties", func(t *testing.T) { - pm := fakePluginManager{ - apps: map[string]*plugins.AppPlugin{ + pm := fakePluginStore{ + apps: map[string]*plugins.Plugin{ "test-plugin": {}, "test-plugin-2": {}, }, @@ -87,13 +87,12 @@ func TestConfigReader(t *testing.T) { }) } -type fakePluginManager struct { - plugins.Manager +type fakePluginStore struct { + plugins.Store - apps map[string]*plugins.AppPlugin + apps map[string]*plugins.Plugin } -func (pm fakePluginManager) IsAppInstalled(id string) bool { - _, exists := pm.apps[id] - return exists +func (pr fakePluginStore) Plugin(pluginID string) *plugins.Plugin { + return pr.apps[pluginID] } diff --git a/pkg/services/provisioning/plugins/plugin_provisioner.go b/pkg/services/provisioning/plugins/plugin_provisioner.go index 1f26c6499d2..5defe3b7c5d 100644 --- a/pkg/services/provisioning/plugins/plugin_provisioner.go +++ b/pkg/services/provisioning/plugins/plugin_provisioner.go @@ -11,11 +11,11 @@ import ( // Provision scans a directory for provisioning config files // and provisions the app in those files. -func Provision(configDirectory string, pluginManager plugins.Manager) error { +func Provision(configDirectory string, pluginStore plugins.Store) error { logger := log.New("provisioning.plugins") ap := PluginProvisioner{ log: logger, - cfgProvider: newConfigReader(logger, pluginManager), + cfgProvider: newConfigReader(logger, pluginStore), } return ap.applyChanges(configDirectory) } diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 52aa9215624..843701f3b5f 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -18,12 +18,12 @@ import ( "github.com/grafana/grafana/pkg/util/errutil" ) -func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginManager plugifaces.Manager, +func ProvideService(cfg *setting.Cfg, sqlStore *sqlstore.SQLStore, pluginStore plugifaces.Store, encryptionService encryption.Service) (*ProvisioningServiceImpl, error) { s := &ProvisioningServiceImpl{ Cfg: cfg, SQLStore: sqlStore, - PluginManager: pluginManager, + pluginStore: pluginStore, EncryptionService: encryptionService, log: log.New("provisioning"), newDashboardProvisioner: dashboards.New, @@ -61,7 +61,7 @@ func newProvisioningServiceImpl( newDashboardProvisioner dashboards.DashboardProvisionerFactory, provisionNotifiers func(string, encryption.Service) error, provisionDatasources func(context.Context, string) error, - provisionPlugins func(string, plugifaces.Manager) error, + provisionPlugins func(string, plugifaces.Store) error, ) *ProvisioningServiceImpl { return &ProvisioningServiceImpl{ log: log.New("provisioning"), @@ -75,7 +75,7 @@ func newProvisioningServiceImpl( type ProvisioningServiceImpl struct { Cfg *setting.Cfg SQLStore *sqlstore.SQLStore - PluginManager plugifaces.Manager + pluginStore plugifaces.Store EncryptionService encryption.Service log log.Logger pollingCtxCancel context.CancelFunc @@ -83,7 +83,7 @@ type ProvisioningServiceImpl struct { dashboardProvisioner dashboards.DashboardProvisioner provisionNotifiers func(string, encryption.Service) error provisionDatasources func(context.Context, string) error - provisionPlugins func(string, plugifaces.Manager) error + provisionPlugins func(string, plugifaces.Store) error mutex sync.Mutex } @@ -143,7 +143,7 @@ func (ps *ProvisioningServiceImpl) ProvisionDatasources(ctx context.Context) err func (ps *ProvisioningServiceImpl) ProvisionPlugins() error { appPath := filepath.Join(ps.Cfg.ProvisioningPath, "plugins") - err := ps.provisionPlugins(appPath, ps.PluginManager) + err := ps.provisionPlugins(appPath, ps.pluginStore) return errutil.Wrap("app provisioning error", err) } diff --git a/pkg/services/rendering/plugin_mode.go b/pkg/services/rendering/plugin_mode.go index 316a7d52bca..83bee4fe6e5 100644 --- a/pkg/services/rendering/plugin_mode.go +++ b/pkg/services/rendering/plugin_mode.go @@ -45,7 +45,7 @@ func (rs *RenderingService) renderViaPlugin(ctx context.Context, renderKey strin } rs.log.Debug("Calling renderer plugin", "req", req) - rsp, err := rs.pluginInfo.GrpcPluginV2.Render(ctx, req) + rsp, err := rs.pluginInfo.Renderer.Render(ctx, req) if errors.Is(ctx.Err(), context.DeadlineExceeded) { rs.log.Info("Rendering timed out") return nil, ErrTimeout @@ -88,7 +88,7 @@ func (rs *RenderingService) renderCSVViaPlugin(ctx context.Context, renderKey st } rs.log.Debug("Calling renderer plugin", "req", req) - rsp, err := rs.pluginInfo.GrpcPluginV2.RenderCSV(ctx, req) + rsp, err := rs.pluginInfo.Renderer.RenderCSV(ctx, req) if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { rs.log.Info("Rendering timed out") diff --git a/pkg/services/rendering/rendering.go b/pkg/services/rendering/rendering.go index 0d8aadf259c..b7e0652f791 100644 --- a/pkg/services/rendering/rendering.go +++ b/pkg/services/rendering/rendering.go @@ -13,10 +13,9 @@ import ( "sync/atomic" "time" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/remotecache" - - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" @@ -38,19 +37,19 @@ type RenderUser struct { type RenderingService struct { log log.Logger - pluginInfo *plugins.RendererPlugin + pluginInfo *plugins.Plugin renderAction renderFunc renderCSVAction renderCSVFunc domain string inProgressCount int32 version string - Cfg *setting.Cfg - RemoteCacheService *remotecache.RemoteCache - PluginManager plugins.Manager + Cfg *setting.Cfg + RemoteCacheService *remotecache.RemoteCache + RendererPluginManager plugins.RendererManager } -func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, pm plugins.Manager) (*RenderingService, error) { +func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, rm plugins.RendererManager) (*RenderingService, error) { // ensure ImagesDir exists err := os.MkdirAll(cfg.ImagesDir, 0700) if err != nil { @@ -81,11 +80,11 @@ func ProvideService(cfg *setting.Cfg, remoteCache *remotecache.RemoteCache, pm p } s := &RenderingService{ - Cfg: cfg, - RemoteCacheService: remoteCache, - PluginManager: pm, - log: log.New("rendering"), - domain: domain, + Cfg: cfg, + RemoteCacheService: remoteCache, + RendererPluginManager: rm, + log: log.New("rendering"), + domain: domain, } return s, nil } @@ -109,7 +108,7 @@ func (rs *RenderingService) Run(ctx context.Context) error { if rs.pluginAvailable() { rs.log = rs.log.New("renderer", "plugin") - rs.pluginInfo = rs.PluginManager.Renderer() + rs.pluginInfo = rs.RendererPluginManager.Renderer() if err := rs.startPlugin(ctx); err != nil { return err @@ -142,7 +141,7 @@ func (rs *RenderingService) Run(ctx context.Context) error { } func (rs *RenderingService) pluginAvailable() bool { - return rs.PluginManager.Renderer() != nil + return rs.RendererPluginManager.Renderer() != nil } func (rs *RenderingService) remoteAvailable() bool { @@ -157,7 +156,7 @@ func (rs *RenderingService) Version() string { return rs.version } -func (rs *RenderingService) RenderErrorImage(err error) (*RenderResult, error) { +func (rs *RenderingService) RenderErrorImage(_ error) (*RenderResult, error) { imgUrl := "public/img/rendering_error.png" imgPath := filepath.Join(setting.HomePath, imgUrl) if _, err := os.Stat(imgPath); errors.Is(err, os.ErrNotExist) { diff --git a/pkg/services/updatechecker/grafana_update_checker.go b/pkg/services/updatechecker/grafana_update_checker.go new file mode 100644 index 00000000000..fd2fa3fac82 --- /dev/null +++ b/pkg/services/updatechecker/grafana_update_checker.go @@ -0,0 +1,120 @@ +package updatechecker + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "strings" + "sync" + "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} + logger = log.New("update.checker") +) + +type latestJSON struct { + Stable string `json:"stable"` + Testing string `json:"testing"` +} + +type Service struct { + cfg *setting.Cfg + + hasUpdate bool + latestVersion string + mutex sync.RWMutex +} + +func ProvideService(cfg *setting.Cfg) *Service { + s := newUpdateChecker(cfg) + + return s +} + +func newUpdateChecker(cfg *setting.Cfg) *Service { + return &Service{ + cfg: cfg, + } +} + +func (s *Service) IsDisabled() bool { + return !s.cfg.CheckForUpdates +} + +func (s *Service) Run(ctx context.Context) error { + s.checkForUpdates() + + ticker := time.NewTicker(time.Minute * 10) + run := true + + for run { + select { + case <-ticker.C: + s.checkForUpdates() + case <-ctx.Done(): + run = false + } + } + + return ctx.Err() +} + +func (s *Service) checkForUpdates() { + resp, err := httpClient.Get("https://raw.githubusercontent.com/grafana/grafana/main/latest.json") + if err != nil { + logger.Debug("Failed to get latest.json repo from github.com", "error", err) + return + } + defer func() { + if err := resp.Body.Close(); err != nil { + logger.Warn("Failed to close response body", "err", err) + } + }() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + logger.Debug("Update check failed, reading response from github.com", "error", err) + return + } + + var latest latestJSON + err = json.Unmarshal(body, &latest) + if err != nil { + logger.Debug("Failed to unmarshal latest.json", "error", err) + return + } + + s.mutex.Lock() + defer s.mutex.Unlock() + if strings.Contains(s.cfg.BuildVersion, "-") { + s.latestVersion = latest.Testing + s.hasUpdate = !strings.HasPrefix(s.cfg.BuildVersion, latest.Testing) + } else { + s.latestVersion = latest.Stable + s.hasUpdate = latest.Stable != s.cfg.BuildVersion + } + + currVersion, err1 := version.NewVersion(s.cfg.BuildVersion) + latestVersion, err2 := version.NewVersion(s.latestVersion) + if err1 == nil && err2 == nil { + s.hasUpdate = currVersion.LessThan(latestVersion) + } +} + +func (s *Service) GrafanaUpdateAvailable() bool { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.hasUpdate +} + +func (s *Service) LatestGrafanaVersion() string { + s.mutex.RLock() + defer s.mutex.RUnlock() + return s.latestVersion +} diff --git a/pkg/tests/api/plugins/api_install_test.go b/pkg/tests/api/plugins/api_install_test.go deleted file mode 100644 index 03b7db4e21c..00000000000 --- a/pkg/tests/api/plugins/api_install_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package plugins - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "testing" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/tests/testinfra" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - usernameAdmin = "admin" - usernameNonAdmin = "nonAdmin" - defaultPassword = "password" -) - -func TestPluginInstallAccess(t *testing.T) { - dir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ - PluginAdminEnabled: true, - }) - - grafanaListedAddr, store := testinfra.StartGrafana(t, dir, cfgPath) - store.Bus = bus.GetBus() // in order to allow successful user auth - - createUser(t, store, usernameNonAdmin, defaultPassword, false) - createUser(t, store, usernameAdmin, defaultPassword, true) - - t.Run("Request is forbidden if not from an admin", func(t *testing.T) { - status, body := makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/install")) - assert.Equal(t, 403, status) - assert.Equal(t, "Permission denied", body["message"]) - - status, body = makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/uninstall")) - assert.Equal(t, 403, status) - assert.Equal(t, "Permission denied", body["message"]) - }) - - t.Run("Request is not forbidden if from an admin", func(t *testing.T) { - statusCode, body := makePostRequest(t, grafanaAPIURL(usernameAdmin, grafanaListedAddr, "plugins/test/install")) - - assert.Equal(t, 404, statusCode) - assert.Equal(t, "Plugin not found", body["message"]) - - statusCode, body = makePostRequest(t, grafanaAPIURL(usernameAdmin, grafanaListedAddr, "plugins/test/uninstall")) - assert.Equal(t, 404, statusCode) - assert.Equal(t, "Plugin not installed", body["message"]) - }) -} - -func createUser(t *testing.T, store *sqlstore.SQLStore, username, password string, isAdmin bool) { - t.Helper() - - cmd := models.CreateUserCommand{ - Login: username, - Password: password, - IsAdmin: isAdmin, - } - _, err := store.CreateUser(context.Background(), cmd) - require.NoError(t, err) -} - -func makePostRequest(t *testing.T, URL string) (int, map[string]interface{}) { - t.Helper() - - // nolint:gosec - resp, err := http.Post(URL, "application/json", bytes.NewBufferString("")) - require.NoError(t, err) - t.Cleanup(func() { - _ = resp.Body.Close() - log.Warn("Failed to close response body", "err", err) - }) - b, err := ioutil.ReadAll(resp.Body) - require.NoError(t, err) - - var body = make(map[string]interface{}) - err = json.Unmarshal(b, &body) - require.NoError(t, err) - - return resp.StatusCode, body -} - -func grafanaAPIURL(username string, grafanaListedAddr string, path string) string { - return fmt.Sprintf("http://%s:%s@%s/api/%s", username, defaultPassword, grafanaListedAddr, path) -} diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go new file mode 100644 index 00000000000..18fc33ea3c2 --- /dev/null +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -0,0 +1,136 @@ +package plugins + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/tests/testinfra" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + usernameAdmin = "admin" + usernameNonAdmin = "nonAdmin" + defaultPassword = "password" +) + +func TestPlugins(t *testing.T) { + dir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + PluginAdminEnabled: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, cfgPath) + + type testCase struct { + desc string + url string + expStatus int + expResp string + } + + t.Run("Install", func(t *testing.T) { + store.Bus = bus.GetBus() + + createUser(t, store, models.CreateUserCommand{Login: usernameNonAdmin, Password: defaultPassword, IsAdmin: false}) + createUser(t, store, models.CreateUserCommand{Login: usernameAdmin, Password: defaultPassword, IsAdmin: true}) + + t.Run("Request is forbidden if not from an admin", func(t *testing.T) { + status, body := makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/install")) + assert.Equal(t, 403, status) + assert.Equal(t, "Permission denied", body["message"]) + + status, body = makePostRequest(t, grafanaAPIURL(usernameNonAdmin, grafanaListedAddr, "plugins/grafana-plugin/uninstall")) + assert.Equal(t, 403, status) + assert.Equal(t, "Permission denied", body["message"]) + }) + + t.Run("Request is not forbidden if from an admin", func(t *testing.T) { + statusCode, body := makePostRequest(t, grafanaAPIURL(usernameAdmin, grafanaListedAddr, "plugins/test/install")) + + assert.Equal(t, 404, statusCode) + assert.Equal(t, "Plugin not found", body["message"]) + + statusCode, body = makePostRequest(t, grafanaAPIURL(usernameAdmin, grafanaListedAddr, "plugins/test/uninstall")) + assert.Equal(t, 404, statusCode) + assert.Equal(t, "Plugin not installed", body["message"]) + }) + }) + + t.Run("List", func(t *testing.T) { + testCases := []testCase{ + { + desc: "should return all loaded core and bundled plugins", + url: "http://%s/api/plugins", + expStatus: http.StatusOK, + expResp: expectedResp(t, "expectedListResp.json"), + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + url := fmt.Sprintf(tc.url, grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(url) + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) + require.NoError(t, err) + require.Equal(t, tc.expStatus, resp.StatusCode) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, tc.expResp, string(b)) + }) + } + }) +} + +func createUser(t *testing.T, store *sqlstore.SQLStore, cmd models.CreateUserCommand) { + t.Helper() + + _, err := store.CreateUser(context.Background(), cmd) + require.NoError(t, err) +} + +func makePostRequest(t *testing.T, URL string) (int, map[string]interface{}) { + t.Helper() + + // nolint:gosec + resp, err := http.Post(URL, "application/json", bytes.NewBufferString("")) + require.NoError(t, err) + t.Cleanup(func() { + _ = resp.Body.Close() + log.Warn("Failed to close response body", "err", err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + var body = make(map[string]interface{}) + err = json.Unmarshal(b, &body) + require.NoError(t, err) + + return resp.StatusCode, body +} + +func grafanaAPIURL(username string, grafanaListedAddr string, path string) string { + return fmt.Sprintf("http://%s:%s@%s/api/%s", username, defaultPassword, grafanaListedAddr, path) +} + +func expectedResp(t *testing.T, filename string) string { + contents, err := ioutil.ReadFile(fmt.Sprintf("data/%s", filename)) + if err != nil { + t.Errorf("failed to load %s: %v", filename, err) + } + + return string(contents) +} diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json new file mode 100644 index 00000000000..6b34e72672b --- /dev/null +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -0,0 +1,1400 @@ +[ + { + "name":"Alert list", + "type":"panel", + "id":"alertlist", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Shows list of alerts and their current status", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg", + "large":"public/app/plugins/panel/alertlist/img/icn-singlestat-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/alertlist/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Annotations list", + "type":"panel", + "id":"annolist", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"List annotations", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/annolist/img/icn-annolist-panel.svg", + "large":"public/app/plugins/panel/annolist/img/icn-annolist-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/annolist/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Azure Monitor", + "type":"datasource", + "id":"grafana-azure-monitor-datasource", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Data source for Microsoft Azure Monitor & Application Insights", + "links":[ + { + "name":"Learn more", + "url":"https://github.com/grafana/azure-monitor-datasource" + }, + { + "name":"Apache License", + "url":"https://github.com/grafana/azure-monitor-datasource/blob/master/LICENSE" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/grafana-azure-monitor-datasource/img/logo.jpg", + "large":"public/app/plugins/datasource/grafana-azure-monitor-datasource/img/logo.jpg" + }, + "build":{ + + }, + "screenshots":[ + { + "name":"Azure Contoso Loans", + "path":"public/app/plugins/datasource/grafana-azure-monitor-datasource/img/contoso_loans_grafana_dashboard.png" + }, + { + "name":"Azure Monitor Network", + "path":"public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_network.png" + }, + { + "name":"Azure Monitor CPU", + "path":"public/app/plugins/datasource/grafana-azure-monitor-datasource/img/azure_monitor_cpu.png" + } + ], + "version":"0.3.0", + "updated":"2018-12-06" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/grafana-azure-monitor-datasource/", + "category":"cloud", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Bar chart", + "type":"panel", + "id":"barchart", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Categorical charts with group support", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/barchart/img/barchart.svg", + "large":"public/app/plugins/panel/barchart/img/barchart.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/barchart/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Bar gauge", + "type":"panel", + "id":"bargauge", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Horizontal and vertical gauges", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg", + "large":"public/app/plugins/panel/bargauge/img/icon_bar_gauge.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/bargauge/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"CloudWatch", + "type":"datasource", + "id":"cloudwatch", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Data source for Amazon AWS monitoring service", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png", + "large":"public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/cloudwatch/", + "category":"cloud", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Dashboard list", + "type":"panel", + "id":"dashlist", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"List of dynamic links to other dashboards", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg", + "large":"public/app/plugins/panel/dashlist/img/icn-dashlist-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/dashlist/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Elasticsearch", + "type":"datasource", + "id":"elasticsearch", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Open source logging & analytics database", + "links":[ + { + "name":"Learn more", + "url":"https://grafana.com/docs/features/datasources/elasticsearch/" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg", + "large":"public/app/plugins/datasource/elasticsearch/img/elasticsearch.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/elasticsearch/", + "category":"logging", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Gauge", + "type":"panel", + "id":"gauge", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Standard gauge visualization", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/gauge/img/icon_gauge.svg", + "large":"public/app/plugins/panel/gauge/img/icon_gauge.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/gauge/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Geomap", + "type":"panel", + "id":"geomap", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Geomap panel", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/geomap/img/icn-geomap.svg", + "large":"public/app/plugins/panel/geomap/img/icn-geomap.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/geomap/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Getting Started", + "type":"panel", + "id":"gettingstarted", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg", + "large":"public/app/plugins/panel/gettingstarted/img/icn-dashlist-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/gettingstarted/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Google Cloud Monitoring", + "type":"datasource", + "id":"stackdriver", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Data source for Google's monitoring service (formerly named Stackdriver)", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg", + "large":"public/app/plugins/datasource/cloud-monitoring/img/cloud_monitoring_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"1.0.0", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/stackdriver/", + "category":"cloud", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Graph (old)", + "type":"panel", + "id":"graph", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"The old default graph panel", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/graph/img/icn-graph-panel.svg", + "large":"public/app/plugins/panel/graph/img/icn-graph-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/graph/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Graphite", + "type":"datasource", + "id":"graphite", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Open source time series database", + "links":[ + { + "name":"Learn more", + "url":"https://graphiteapp.org/" + }, + { + "name":"Graphite 1.1 Release", + "url":"https://grafana.com/blog/2018/01/11/graphite-1.1-teaching-an-old-dog-new-tricks/" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/graphite/img/graphite_logo.png", + "large":"public/app/plugins/datasource/graphite/img/graphite_logo.png" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/graphite/", + "category":"tsdb", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Heatmap", + "type":"panel", + "id":"heatmap", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Like a histogram over time", + "links":[ + { + "name":"Brendan Gregg - Heatmaps", + "url":"http://www.brendangregg.com/heatmaps.html" + }, + { + "name":"Brendan Gregg - Latency Heatmaps", + "url":" http://www.brendangregg.com/HeatMaps/latency.html" + } + ], + "logos":{ + "small":"public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg", + "large":"public/app/plugins/panel/heatmap/img/icn-heatmap-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/heatmap/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Histogram", + "type":"panel", + "id":"histogram", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/histogram/img/histogram.svg", + "large":"public/app/plugins/panel/histogram/img/histogram.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/histogram/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"InfluxDB", + "type":"datasource", + "id":"influxdb", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Open source time series database", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/influxdb/img/influxdb_logo.svg", + "large":"public/app/plugins/datasource/influxdb/img/influxdb_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/influxdb/", + "category":"tsdb", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Jaeger", + "type":"datasource", + "id":"jaeger", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Open source, end-to-end distributed tracing", + "links":[ + { + "name":"Learn more", + "url":"https://www.jaegertracing.io" + }, + { + "name":"GitHub Project", + "url":"https://github.com/jaegertracing/jaeger" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/jaeger/img/jaeger_logo.svg", + "large":"public/app/plugins/datasource/jaeger/img/jaeger_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/jaeger/", + "category":"tracing", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Logs", + "type":"panel", + "id":"logs", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/logs/img/icn-logs-panel.svg", + "large":"public/app/plugins/panel/logs/img/icn-logs-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/logs/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Loki", + "type":"datasource", + "id":"loki", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Like Prometheus but for logs. OSS logging solution from Grafana Labs", + "links":[ + { + "name":"Learn more", + "url":"https://grafana.com/loki" + }, + { + "name":"GitHub Project", + "url":"https://github.com/grafana/loki" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/loki/img/loki_icon.svg", + "large":"public/app/plugins/datasource/loki/img/loki_icon.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/loki/", + "category":"logging", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Microsoft SQL Server", + "type":"datasource", + "id":"mssql", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Data source for Microsoft SQL Server compatible databases", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/mssql/img/sql_server_logo.svg", + "large":"public/app/plugins/datasource/mssql/img/sql_server_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/mssql/", + "category":"sql", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"MySQL", + "type":"datasource", + "id":"mysql", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Data source for MySQL databases", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/mysql/img/mysql_logo.svg", + "large":"public/app/plugins/datasource/mysql/img/mysql_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/mysql/", + "category":"sql", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"News", + "type":"panel", + "id":"news", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"RSS feed reader", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/news/img/news.svg", + "large":"public/app/plugins/panel/news/img/news.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/news/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Node Graph", + "type":"panel", + "id":"nodeGraph", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg", + "large":"public/app/plugins/panel/nodeGraph/img/icn-node-graph.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/nodeGraph/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"OpenTSDB", + "type":"datasource", + "id":"opentsdb", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Open source time series database", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png", + "large":"public/app/plugins/datasource/opentsdb/img/opentsdb_logo.png" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/opentsdb/", + "category":"tsdb", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Pie chart", + "type":"panel", + "id":"piechart", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"The new core pie chart visualization", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/piechart/img/icon_piechart.svg", + "large":"public/app/plugins/panel/piechart/img/icon_piechart.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/piechart/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Plugin list", + "type":"panel", + "id":"pluginlist", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Plugin List for Grafana", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg", + "large":"public/app/plugins/panel/pluginlist/img/icn-dashlist-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/pluginlist/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"PostgreSQL", + "type":"datasource", + "id":"postgres", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Data source for PostgreSQL and compatible databases", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/postgres/img/postgresql_logo.svg", + "large":"public/app/plugins/datasource/postgres/img/postgresql_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/postgres/", + "category":"sql", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Prometheus", + "type":"datasource", + "id":"prometheus", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Open source time series database & alerting", + "links":[ + { + "name":"Learn more", + "url":"https://prometheus.io/" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/prometheus/img/prometheus_logo.svg", + "large":"public/app/plugins/datasource/prometheus/img/prometheus_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/prometheus/", + "category":"tsdb", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Stat", + "type":"panel", + "id":"stat", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Big stat values & sparklines", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/stat/img/icn-singlestat-panel.svg", + "large":"public/app/plugins/panel/stat/img/icn-singlestat-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/stat/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"State timeline", + "type":"panel", + "id":"state-timeline", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"State changes and durations", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/state-timeline/img/timeline.svg", + "large":"public/app/plugins/panel/state-timeline/img/timeline.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/state-timeline/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Status history", + "type":"panel", + "id":"status-history", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Periodic status history", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/status-history/img/status.svg", + "large":"public/app/plugins/panel/status-history/img/status.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/status-history/", + "category":"", + "state":"beta", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Table", + "type":"panel", + "id":"table", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Supports many column styles", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/table/img/icn-table-panel.svg", + "large":"public/app/plugins/panel/table/img/icn-table-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/table/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Table (old)", + "type":"panel", + "id":"table-old", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Table Panel for Grafana", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/table-old/img/icn-table-panel.svg", + "large":"public/app/plugins/panel/table-old/img/icn-table-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/table-old/", + "category":"", + "state":"deprecated", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Tempo", + "type":"datasource", + "id":"tempo", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"High volume, minimal dependency trace storage. OSS tracing solution from Grafana Labs.", + "links":[ + { + "name":"GitHub Project", + "url":"https://github.com/grafana/tempo" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/tempo/img/tempo_logo.svg", + "large":"public/app/plugins/datasource/tempo/img/tempo_logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/tempo/", + "category":"tracing", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"TestData DB", + "type":"datasource", + "id":"testdata", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Generates test data in different forms", + "links":null, + "logos":{ + "small":"public/app/plugins/datasource/testdata/img/testdata.svg", + "large":"public/app/plugins/datasource/testdata/img/testdata.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/testdata/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Text", + "type":"panel", + "id":"text", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Supports markdown and html content", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/text/img/icn-text-panel.svg", + "large":"public/app/plugins/panel/text/img/icn-text-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/text/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Time series", + "type":"panel", + "id":"timeseries", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Time based line, area and bar charts", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg", + "large":"public/app/plugins/panel/timeseries/img/icn-timeseries-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/timeseries/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Welcome", + "type":"panel", + "id":"welcome", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"", + "links":null, + "logos":{ + "small":"public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg", + "large":"public/app/plugins/panel/welcome/img/icn-dashlist-panel.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/welcome/", + "category":"", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + }, + { + "name":"Zipkin", + "type":"datasource", + "id":"zipkin", + "enabled":true, + "pinned":false, + "info":{ + "author":{ + "name":"Grafana Labs", + "url":"https://grafana.com" + }, + "description":"Placeholder for the distributed tracing system.", + "links":[ + { + "name":"Learn more", + "url":"https://zipkin.io" + } + ], + "logos":{ + "small":"public/app/plugins/datasource/zipkin/img/zipkin-logo.svg", + "large":"public/app/plugins/datasource/zipkin/img/zipkin-logo.svg" + }, + "build":{ + + }, + "screenshots":null, + "version":"", + "updated":"" + }, + "latestVersion":"", + "hasUpdate":false, + "defaultNavUrl":"/plugins/zipkin/", + "category":"tracing", + "state":"", + "signature":"internal", + "signatureType":"", + "signatureOrg":"" + } +] diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index e24fede763b..babc48cc123 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -157,6 +157,9 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { provDashboardsDir := filepath.Join(provDir, "dashboards") err = os.MkdirAll(provDashboardsDir, 0750) require.NoError(t, err) + corePluginsDir := filepath.Join(publicDir, "app/plugins") + err = fs.CopyRecursive(filepath.Join(rootDir, "public", "app/plugins"), corePluginsDir) + require.NoError(t, err) cfg := ini.Empty() dfltSect := cfg.Section("") diff --git a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go index 03e0532a822..838fa2126f5 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go +++ b/pkg/tsdb/azuremonitor/azuremonitor-resource-handler.go @@ -115,7 +115,7 @@ func (s *Service) resourceHandler(subDataSource string) func(rw http.ResponseWri } } -// Route definitions shared with the frontend. +// registerRoutes provides route definitions shared with the frontend. // Check: /public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/common.ts func (s *Service) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/azuremonitor/", s.resourceHandler(azureMonitor)) diff --git a/pkg/tsdb/azuremonitor/azuremonitor.go b/pkg/tsdb/azuremonitor/azuremonitor.go index 41a24ae2e71..978afffd0c7 100644 --- a/pkg/tsdb/azuremonitor/azuremonitor.go +++ b/pkg/tsdb/azuremonitor/azuremonitor.go @@ -15,7 +15,6 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/azcredentials" @@ -31,7 +30,7 @@ var ( legendKeyFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) ) -func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, pluginManager plugins.Manager, backendPluginManager backendplugin.Manager) *Service { +func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, registrar plugins.CoreBackendRegistrar) *Service { proxy := &httpServiceProxy{} executors := map[string]azDatasourceExecutor{ azureMonitor: &AzureMonitorDatasource{proxy: proxy}, @@ -40,14 +39,12 @@ func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, p insightsAnalytics: &InsightsAnalyticsDatasource{proxy: proxy}, azureResourceGraph: &AzureResourceGraphDatasource{proxy: proxy}, } - im := datasource.NewInstanceManager(NewInstanceSettings(cfg, *httpClientProvider, executors)) s := &Service{ - Cfg: cfg, - PluginManager: pluginManager, - im: im, - executors: executors, + Cfg: cfg, + im: im, + executors: executors, } mux := s.newMux() @@ -58,9 +55,10 @@ func ProvideService(cfg *setting.Cfg, httpClientProvider *httpclient.Provider, p CallResourceHandler: httpadapter.New(resourceMux), }) - if err := backendPluginManager.RegisterAndStart(context.Background(), dsName, factory); err != nil { + if err := registrar.LoadAndRegister(dsName, factory); err != nil { azlog.Error("Failed to register plugin", "error", err) } + return s } @@ -69,10 +67,9 @@ type serviceProxy interface { } type Service struct { - PluginManager plugins.Manager - Cfg *setting.Cfg - im instancemgmt.InstanceManager - executors map[string]azDatasourceExecutor + Cfg *setting.Cfg + im instancemgmt.InstanceManager + executors map[string]azDatasourceExecutor } type azureMonitorSettings struct { diff --git a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go index a319682e766..1545362ef2a 100644 --- a/pkg/tsdb/cloudmonitoring/cloudmonitoring.go +++ b/pkg/tsdb/cloudmonitoring/cloudmonitoring.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/services/datasources" @@ -62,7 +61,7 @@ var ( ) const ( - dsName = "stackdriver" + pluginID string = "stackdriver" gceAuthentication string = "gce" jwtAuthentication string = "jwt" @@ -73,34 +72,30 @@ const ( perSeriesAlignerDefault string = "ALIGN_MEAN" ) -func ProvideService(cfg *setting.Cfg, httpClientProvider httpclient.Provider, pluginManager plugins.Manager, - backendPluginManager backendplugin.Manager, dsService *datasources.Service) *Service { +func ProvideService(cfg *setting.Cfg, httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar, + dsService *datasources.Service) *Service { s := &Service{ - pluginManager: pluginManager, - backendPluginManager: backendPluginManager, - httpClientProvider: httpClientProvider, - cfg: cfg, - im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), - dsService: dsService, + httpClientProvider: httpClientProvider, + cfg: cfg, + im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), + dsService: dsService, } factory := coreplugin.New(backend.ServeOpts{ QueryDataHandler: s, }) - if err := s.backendPluginManager.Register(dsName, factory); err != nil { + if err := registrar.LoadAndRegister(pluginID, factory); err != nil { slog.Error("Failed to register plugin", "error", err) } return s } type Service struct { - pluginManager plugins.Manager - backendPluginManager backendplugin.Manager - httpClientProvider httpclient.Provider - cfg *setting.Cfg - im instancemgmt.InstanceManager - dsService *datasources.Service + httpClientProvider httpclient.Provider + cfg *setting.Cfg + im instancemgmt.InstanceManager + dsService *datasources.Service } type QueryModel struct { @@ -173,9 +168,8 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst } } -// Query takes in the frontend queries, parses them into the CloudMonitoring query format -// executes the queries against the CloudMonitoring API and parses the response into -// the data frames +// QueryData takes in the frontend queries, parses them into the CloudMonitoring query format +// executes the queries against the CloudMonitoring API and parses the response into data frames func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() if len(req.Queries) == 0 { @@ -571,7 +565,7 @@ func calcBucketBound(bucketOptions cloudMonitoringBucketOptions, n int) string { return bucketBound } -func (s *Service) createRequest(ctx context.Context, pluginCtx backend.PluginContext, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { +func (s *Service) createRequest(ctx context.Context, dsInfo *datasourceInfo, proxyPass string, body io.Reader) (*http.Request, error) { u, err := url.Parse(dsInfo.url) if err != nil { return nil, err diff --git a/pkg/tsdb/cloudmonitoring/time_series_filter.go b/pkg/tsdb/cloudmonitoring/time_series_filter.go index 8f80c9c4adf..223f3bf4f09 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_filter.go +++ b/pkg/tsdb/cloudmonitoring/time_series_filter.go @@ -29,7 +29,7 @@ func (timeSeriesFilter *cloudMonitoringTimeSeriesFilter) run(ctx context.Context slog.Info("No project name set on query, using project name from datasource", "projectName", projectName) } - r, err := s.createRequest(ctx, req.PluginContext, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries"), nil) + r, err := s.createRequest(ctx, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries"), nil) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil diff --git a/pkg/tsdb/cloudmonitoring/time_series_query.go b/pkg/tsdb/cloudmonitoring/time_series_query.go index b8320e8ffcf..3fa0d4dd414 100644 --- a/pkg/tsdb/cloudmonitoring/time_series_query.go +++ b/pkg/tsdb/cloudmonitoring/time_series_query.go @@ -49,7 +49,7 @@ func (timeSeriesQuery cloudMonitoringTimeSeriesQuery) run(ctx context.Context, r dr.Error = err return dr, cloudMonitoringResponse{}, "", nil } - r, err := s.createRequest(ctx, req.PluginContext, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries:query"), bytes.NewBuffer(buf)) + r, err := s.createRequest(ctx, &dsInfo, path.Join("/v3/projects", projectName, "timeSeries:query"), bytes.NewBuffer(buf)) if err != nil { dr.Error = err return dr, cloudMonitoringResponse{}, "", nil diff --git a/pkg/tsdb/cloudwatch/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index d81962f3045..3b0c91a13aa 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -25,7 +25,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" ) @@ -55,31 +55,30 @@ const logStreamIdentifierInternal = "__logstream__grafana_internal__" var plog = log.New("tsdb.cloudwatch") var aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) -func ProvideService(cfg *setting.Cfg, logsService *LogsService, backendPM backendplugin.Manager) (*CloudWatchService, error) { +func ProvideService(cfg *setting.Cfg, logsService *LogsService, registrar plugins.CoreBackendRegistrar) (*CloudWatchService, error) { plog.Debug("initing") - im := datasource.NewInstanceManager(NewInstanceSettings()) - + executor := newExecutor(logsService, datasource.NewInstanceManager(NewInstanceSettings()), cfg, awsds.NewSessionCache()) factory := coreplugin.New(backend.ServeOpts{ - QueryDataHandler: newExecutor(logsService, im, cfg, awsds.NewSessionCache()), + QueryDataHandler: executor, }) - if err := backendPM.Register("cloudwatch", factory); err != nil { + if err := registrar.LoadAndRegister("cloudwatch", factory); err != nil { plog.Error("Failed to register plugin", "error", err) return nil, err } return &CloudWatchService{ - LogsService: logsService, - Cfg: cfg, - BackendPluginManager: backendPM, + LogsService: logsService, + Cfg: cfg, + Executor: executor, }, nil } type CloudWatchService struct { - LogsService *LogsService - BackendPluginManager backendplugin.Manager - Cfg *setting.Cfg + LogsService *LogsService + Cfg *setting.Cfg + Executor *cloudWatchExecutor } type SessionCache interface { diff --git a/pkg/tsdb/elasticsearch/elasticsearch.go b/pkg/tsdb/elasticsearch/elasticsearch.go index e641550d7b0..cda99ee6d3d 100644 --- a/pkg/tsdb/elasticsearch/elasticsearch.go +++ b/pkg/tsdb/elasticsearch/elasticsearch.go @@ -12,7 +12,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client" "github.com/grafana/grafana/pkg/tsdb/intervalv2" @@ -26,7 +26,7 @@ type Service struct { im instancemgmt.InstanceManager } -func ProvideService(httpClientProvider httpclient.Provider, backendPluginManager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { eslog.Debug("initializing") im := datasource.NewInstanceManager(newInstanceSettings()) @@ -36,7 +36,7 @@ func ProvideService(httpClientProvider httpclient.Provider, backendPluginManager QueryDataHandler: newService(im, s.HTTPClientProvider), }) - if err := backendPluginManager.Register("elasticsearch", factory); err != nil { + if err := registrar.LoadAndRegister("elasticsearch", factory); err != nil { eslog.Error("Failed to register plugin", "error", err) return nil, err } diff --git a/pkg/tsdb/grafanads/grafana.go b/pkg/tsdb/grafanads/grafana.go index 0e2e74a2e0d..da6da2007f9 100644 --- a/pkg/tsdb/grafanads/grafana.go +++ b/pkg/tsdb/grafanads/grafana.go @@ -8,17 +8,14 @@ import ( "path/filepath" "strings" - "github.com/grafana/grafana/pkg/models" - - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - - "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/experimental" "github.com/grafana/grafana/pkg/components/simplejson" "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/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/testdatasource" ) @@ -44,11 +41,11 @@ var ( logger = log.New("tsdb.grafana") ) -func ProvideService(cfg *setting.Cfg, backendPM backendplugin.Manager) *Service { - return newService(cfg.StaticRootPath, backendPM) +func ProvideService(cfg *setting.Cfg, registrar plugins.CoreBackendRegistrar) *Service { + return newService(cfg.StaticRootPath, registrar) } -func newService(staticRootPath string, backendPM backendplugin.Manager) *Service { +func newService(staticRootPath string, registrar plugins.CoreBackendRegistrar) *Service { s := &Service{ staticRootPath: staticRootPath, roots: []string{ @@ -60,7 +57,7 @@ func newService(staticRootPath string, backendPM backendplugin.Manager) *Service }, } - if err := backendPM.Register("grafana", coreplugin.New(backend.ServeOpts{ + if err := registrar.LoadAndRegister("grafana", coreplugin.New(backend.ServeOpts{ CheckHealthHandler: s, QueryDataHandler: s, })); err != nil { diff --git a/pkg/tsdb/grafanads/grafana_test.go b/pkg/tsdb/grafanads/grafana_test.go index 3df4df1cc24..55beb54f084 100644 --- a/pkg/tsdb/grafanads/grafana_test.go +++ b/pkg/tsdb/grafanads/grafana_test.go @@ -5,6 +5,7 @@ import ( "path" "testing" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana-plugin-sdk-go/backend" @@ -42,9 +43,9 @@ func TestReadCSVFile(t *testing.T) { } type fakeBackendPM struct { - backendplugin.Manager + plugins.CoreBackendRegistrar } -func (pm *fakeBackendPM) Register(pluginID string, factory backendplugin.PluginFactoryFunc) error { +func (pm *fakeBackendPM) LoadAndRegister(pluginID string, factory backendplugin.PluginFactoryFunc) error { return nil } diff --git a/pkg/tsdb/graphite/graphite.go b/pkg/tsdb/graphite/graphite.go index f0db0e9688f..bc8c623fe46 100644 --- a/pkg/tsdb/graphite/graphite.go +++ b/pkg/tsdb/graphite/graphite.go @@ -24,7 +24,6 @@ import ( "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/opentracing/opentracing-go" @@ -35,7 +34,7 @@ type Service struct { im instancemgmt.InstanceManager } -func ProvideService(httpClientProvider httpclient.Provider, manager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { s := &Service{ logger: log.New("tsdb.graphite"), im: datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)), @@ -45,7 +44,7 @@ func ProvideService(httpClientProvider httpclient.Provider, manager backendplugi QueryDataHandler: s, }) - if err := manager.Register("graphite", factory); err != nil { + if err := registrar.LoadAndRegister("graphite", factory); err != nil { s.logger.Error("Failed to register plugin", "error", err) return nil, err } diff --git a/pkg/tsdb/influxdb/influxdb.go b/pkg/tsdb/influxdb/influxdb.go index a774e48df89..112902d4d31 100644 --- a/pkg/tsdb/influxdb/influxdb.go +++ b/pkg/tsdb/influxdb/influxdb.go @@ -15,7 +15,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/influxdb/flux" @@ -32,7 +32,7 @@ type Service struct { var ErrInvalidHttpMode = errors.New("'httpMode' should be either 'GET' or 'POST'") -func ProvideService(httpClient httpclient.Provider, backendPluginManager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClient httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { im := datasource.NewInstanceManager(newInstanceSettings(httpClient)) s := &Service{ QueryParser: &InfluxdbQueryParser{}, @@ -45,7 +45,7 @@ func ProvideService(httpClient httpclient.Provider, backendPluginManager backend QueryDataHandler: s, }) - if err := backendPluginManager.Register("influxdb", factory); err != nil { + if err := registrar.LoadAndRegister("influxdb", factory); err != nil { s.glog.Error("Failed to register plugin", "error", err) return nil, err } diff --git a/pkg/tsdb/loki/loki.go b/pkg/tsdb/loki/loki.go index 24e9dd86212..91e8d0108db 100644 --- a/pkg/tsdb/loki/loki.go +++ b/pkg/tsdb/loki/loki.go @@ -16,7 +16,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/grafana/loki/pkg/logcli/client" @@ -34,7 +34,7 @@ type Service struct { plog log.Logger } -func ProvideService(httpClientProvider httpclient.Provider, manager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { im := datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)) s := &Service{ im: im, @@ -46,7 +46,7 @@ func ProvideService(httpClientProvider httpclient.Provider, manager backendplugi QueryDataHandler: s, }) - if err := manager.Register("loki", factory); err != nil { + if err := registrar.LoadAndRegister("loki", factory); err != nil { s.plog.Error("Failed to register plugin", "error", err) return nil, err } diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index f769d32c5e4..e2929611e40 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" @@ -30,7 +30,7 @@ type Service struct { im instancemgmt.InstanceManager } -func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager) (*Service, error) { +func ProvideService(cfg *setting.Cfg, registrar plugins.CoreBackendRegistrar) (*Service, error) { s := &Service{ im: datasource.NewInstanceManager(newInstanceSettings(cfg)), } @@ -38,7 +38,7 @@ func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager) (*Service, QueryDataHandler: s, }) - if err := manager.Register("mssql", factory); err != nil { + if err := registrar.LoadAndRegister("mssql", factory); err != nil { logger.Error("Failed to register plugin", "error", err) } return s, nil diff --git a/pkg/tsdb/mysql/mysql.go b/pkg/tsdb/mysql/mysql.go index 5b065c18a52..be7cf885489 100644 --- a/pkg/tsdb/mysql/mysql.go +++ b/pkg/tsdb/mysql/mysql.go @@ -20,7 +20,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" @@ -43,7 +43,7 @@ func characterEscape(s string, escapeChar string) string { return strings.ReplaceAll(s, escapeChar, url.QueryEscape(escapeChar)) } -func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager, httpClientProvider httpclient.Provider) (*Service, error) { +func ProvideService(cfg *setting.Cfg, registrar plugins.CoreBackendRegistrar, httpClientProvider httpclient.Provider) (*Service, error) { s := &Service{ im: datasource.NewInstanceManager(newInstanceSettings(cfg, httpClientProvider)), } @@ -51,7 +51,7 @@ func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager, httpClientP QueryDataHandler: s, }) - if err := manager.Register("mysql", factory); err != nil { + if err := registrar.LoadAndRegister("mysql", factory); err != nil { logger.Error("Failed to register plugin", "error", err) } return s, nil diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index ed0f2ad8d2f..6fd12b41c76 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -2,6 +2,7 @@ package opentsdb import ( "context" + "encoding/json" "fmt" "io/ioutil" "net/http" @@ -11,8 +12,6 @@ import ( "strings" "time" - "encoding/json" - "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" @@ -20,7 +19,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "golang.org/x/net/context/ctxhttp" @@ -31,7 +30,7 @@ type Service struct { im instancemgmt.InstanceManager } -func ProvideService(httpClientProvider httpclient.Provider, manager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { im := datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)) s := &Service{ logger: log.New("tsdb.opentsdb"), @@ -41,8 +40,7 @@ func ProvideService(httpClientProvider httpclient.Provider, manager backendplugi factory := coreplugin.New(backend.ServeOpts{ QueryDataHandler: s, }) - err := manager.RegisterAndStart(context.Background(), "opentsdb", factory) - if err != nil { + if err := registrar.LoadAndRegister("opentsdb", factory); err != nil { return nil, err } diff --git a/pkg/tsdb/postgres/postgres.go b/pkg/tsdb/postgres/postgres.go index f700710244e..a1053f4ca25 100644 --- a/pkg/tsdb/postgres/postgres.go +++ b/pkg/tsdb/postgres/postgres.go @@ -14,7 +14,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/sqleng" @@ -23,7 +23,7 @@ import ( var logger = log.New("tsdb.postgres") -func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager) (*Service, error) { +func ProvideService(cfg *setting.Cfg, registrar plugins.CoreBackendRegistrar) (*Service, error) { s := &Service{ tlsManager: newTLSManager(logger, cfg.DataPath), } @@ -32,7 +32,7 @@ func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager) (*Service, QueryDataHandler: s, }) - if err := manager.Register("postgres", factory); err != nil { + if err := registrar.LoadAndRegister("postgres", factory); err != nil { logger.Error("Failed to register plugin", "error", err) } return s, nil diff --git a/pkg/tsdb/prometheus/prometheus.go b/pkg/tsdb/prometheus/prometheus.go index ef9ad6fcc77..bd0bd34ded7 100644 --- a/pkg/tsdb/prometheus/prometheus.go +++ b/pkg/tsdb/prometheus/prometheus.go @@ -13,7 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/tsdb/intervalv2" "github.com/prometheus/client_golang/api" @@ -31,7 +31,7 @@ type Service struct { im instancemgmt.InstanceManager } -func ProvideService(httpClientProvider httpclient.Provider, backendPluginManager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { plog.Debug("initializing") im := datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)) @@ -43,7 +43,7 @@ func ProvideService(httpClientProvider httpclient.Provider, backendPluginManager factory := coreplugin.New(backend.ServeOpts{ QueryDataHandler: s, }) - if err := backendPluginManager.Register("prometheus", factory); err != nil { + if err := registrar.LoadAndRegister("prometheus", factory); err != nil { plog.Error("Failed to register plugin", "error", err) return nil, err } diff --git a/pkg/tsdb/service.go b/pkg/tsdb/service.go index 916a5dfeeb0..01d427d7f29 100644 --- a/pkg/tsdb/service.go +++ b/pkg/tsdb/service.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/oauthtoken" "github.com/grafana/grafana/pkg/setting" @@ -14,30 +13,30 @@ import ( // NewService returns a new Service. func NewService( - cfg *setting.Cfg, backendPluginManager backendplugin.Manager, - oauthTokenService *oauthtoken.Service, dataSourcesService *datasources.Service) *Service { - return newService(cfg, backendPluginManager, oauthTokenService, dataSourcesService) + cfg *setting.Cfg, pluginsClient plugins.Client, oauthTokenService *oauthtoken.Service, + dataSourcesService *datasources.Service) *Service { + return newService(cfg, pluginsClient, oauthTokenService, dataSourcesService) } -func newService(cfg *setting.Cfg, backendPluginManager backendplugin.Manager, - oauthTokenService oauthtoken.OAuthTokenService, dataSourcesService *datasources.Service) *Service { +func newService(cfg *setting.Cfg, pluginsClient plugins.Client, oauthTokenService oauthtoken.OAuthTokenService, + dataSourcesService *datasources.Service) *Service { return &Service{ - Cfg: cfg, - BackendPluginManager: backendPluginManager, - OAuthTokenService: oauthTokenService, - DataSourcesService: dataSourcesService, + Cfg: cfg, + pluginsClient: pluginsClient, + OAuthTokenService: oauthTokenService, + DataSourcesService: dataSourcesService, } } // Service handles data requests to data sources. type Service struct { - Cfg *setting.Cfg - BackendPluginManager backendplugin.Manager - OAuthTokenService oauthtoken.OAuthTokenService - DataSourcesService *datasources.Service + Cfg *setting.Cfg + pluginsClient plugins.Client + OAuthTokenService oauthtoken.OAuthTokenService + DataSourcesService *datasources.Service } //nolint: staticcheck // plugins.DataPlugin deprecated func (s *Service) HandleRequest(ctx context.Context, ds *models.DataSource, query plugins.DataQuery) (plugins.DataResponse, error) { - return dataPluginQueryAdapter(ds.Type, s.BackendPluginManager, s.OAuthTokenService, s.DataSourcesService).DataQuery(ctx, ds, query) + return dataPluginQueryAdapter(ds.Type, s.pluginsClient, s.OAuthTokenService, s.DataSourcesService).DataQuery(ctx, ds, query) } diff --git a/pkg/tsdb/service_test.go b/pkg/tsdb/service_test.go index f1f321b5e33..6bb5ed441db 100644 --- a/pkg/tsdb/service_test.go +++ b/pkg/tsdb/service_test.go @@ -9,7 +9,6 @@ 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/backendplugin" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" "github.com/grafana/grafana/pkg/setting" @@ -76,12 +75,12 @@ func (e *fakeExecutor) HandleQuery(refId string, fn resultsFn) { e.resultsFn[refId] = fn } -type fakeBackendPM struct { - backendplugin.Manager +type fakePluginsClient struct { + plugins.Client backend.QueryDataHandlerFunc } -func (m *fakeBackendPM) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (m *fakePluginsClient) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { if m.QueryDataHandlerFunc != nil { return m.QueryDataHandlerFunc.QueryData(ctx, req) } @@ -100,12 +99,13 @@ func (s *fakeOAuthTokenService) IsOAuthPassThruEnabled(*models.DataSource) bool return false } -func createService() (*Service, *fakeExecutor, *fakeBackendPM) { - fakeBackendPM := &fakeBackendPM{} +func createService() (*Service, *fakeExecutor, *fakePluginsClient) { + fakePluginsClient := &fakePluginsClient{} dsService := datasources.ProvideService(bus.New(), nil, ossencryption.ProvideService()) + s := newService( setting.NewCfg(), - fakeBackendPM, + fakePluginsClient, &fakeOAuthTokenService{}, dsService, ) @@ -115,5 +115,5 @@ func createService() (*Service, *fakeExecutor, *fakeBackendPM) { resultsFn: make(map[string]resultsFn), } - return s, e, fakeBackendPM + return s, e, fakePluginsClient } diff --git a/pkg/tsdb/tempo/tempo.go b/pkg/tsdb/tempo/tempo.go index aca87ef2102..74ca3ab733d 100644 --- a/pkg/tsdb/tempo/tempo.go +++ b/pkg/tsdb/tempo/tempo.go @@ -13,9 +13,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/httpclient" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "go.opentelemetry.io/collector/model/otlp" ) @@ -24,7 +23,7 @@ type Service struct { tlog log.Logger } -func ProvideService(httpClientProvider httpclient.Provider, manager backendplugin.Manager) (*Service, error) { +func ProvideService(httpClientProvider httpclient.Provider, registrar plugins.CoreBackendRegistrar) (*Service, error) { im := datasource.NewInstanceManager(newInstanceSettings(httpClientProvider)) s := &Service{ @@ -36,7 +35,7 @@ func ProvideService(httpClientProvider httpclient.Provider, manager backendplugi QueryDataHandler: s, }) - if err := manager.Register("tempo", factory); err != nil { + if err := registrar.LoadAndRegister("tempo", factory); err != nil { s.tlog.Error("Failed to register plugin", "error", err) return nil, err } diff --git a/pkg/tsdb/testdatasource/csv_data.go b/pkg/tsdb/testdatasource/csv_data.go index 584663453b3..8154695db05 100644 --- a/pkg/tsdb/testdatasource/csv_data.go +++ b/pkg/tsdb/testdatasource/csv_data.go @@ -18,7 +18,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" ) -func (p *TestDataPlugin) handleCsvContentScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleCsvContentScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -43,7 +43,7 @@ func (p *TestDataPlugin) handleCsvContentScenario(ctx context.Context, req *back return resp, nil } -func (p *TestDataPlugin) handleCsvFileScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleCsvFileScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -58,7 +58,7 @@ func (p *TestDataPlugin) handleCsvFileScenario(ctx context.Context, req *backend continue } - frame, err := p.loadCsvFile(fileName) + frame, err := s.loadCsvFile(fileName) if err != nil { return nil, err @@ -72,14 +72,14 @@ func (p *TestDataPlugin) handleCsvFileScenario(ctx context.Context, req *backend return resp, nil } -func (p *TestDataPlugin) loadCsvFile(fileName string) (*data.Frame, error) { +func (s *Service) loadCsvFile(fileName string) (*data.Frame, error) { validFileName := regexp.MustCompile(`([\w_]+)\.csv`) if !validFileName.MatchString(fileName) { return nil, fmt.Errorf("invalid csv file name: %q", fileName) } - filePath := filepath.Join(p.cfg.StaticRootPath, "testdata", fileName) + filePath := filepath.Join(s.cfg.StaticRootPath, "testdata", fileName) // Can ignore gosec G304 here, because we check the file pattern above // nolint:gosec @@ -90,7 +90,7 @@ func (p *TestDataPlugin) loadCsvFile(fileName string) (*data.Frame, error) { defer func() { if err := fileReader.Close(); err != nil { - p.logger.Warn("Failed to close file", "err", err, "path", fileName) + s.logger.Warn("Failed to close file", "err", err, "path", fileName) } }() diff --git a/pkg/tsdb/testdatasource/csv_data_test.go b/pkg/tsdb/testdatasource/csv_data_test.go index 5226e3f8c8e..60ea2da7265 100644 --- a/pkg/tsdb/testdatasource/csv_data_test.go +++ b/pkg/tsdb/testdatasource/csv_data_test.go @@ -17,7 +17,7 @@ func TestCSVFileScenario(t *testing.T) { cfg.DataPath = t.TempDir() cfg.StaticRootPath = "../../../public" - p := &TestDataPlugin{ + s := &Service{ cfg: cfg, } @@ -50,7 +50,7 @@ func TestCSVFileScenario(t *testing.T) { } t.Run("Should not allow non file name chars", func(t *testing.T) { - _, err := p.loadCsvFile("../population_by_state.csv") + _, err := s.loadCsvFile("../population_by_state.csv") require.Error(t, err) }) }) diff --git a/pkg/tsdb/testdatasource/flight_path.go b/pkg/tsdb/testdatasource/flight_path.go index 79b0ad87a34..1176768f92f 100644 --- a/pkg/tsdb/testdatasource/flight_path.go +++ b/pkg/tsdb/testdatasource/flight_path.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" ) -func (p *TestDataPlugin) handleFlightPathScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleFlightPathScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { diff --git a/pkg/tsdb/testdatasource/flight_path_test.go b/pkg/tsdb/testdatasource/flight_path_test.go index 456e292e75e..24ca8a80755 100644 --- a/pkg/tsdb/testdatasource/flight_path_test.go +++ b/pkg/tsdb/testdatasource/flight_path_test.go @@ -16,7 +16,7 @@ import ( func TestFlightPathScenario(t *testing.T) { cfg := setting.NewCfg() - p := &TestDataPlugin{ + s := &Service{ cfg: cfg, } @@ -37,7 +37,7 @@ func TestFlightPathScenario(t *testing.T) { }, } - rsp, err := p.handleFlightPathScenario(context.Background(), qr) + rsp, err := s.handleFlightPathScenario(context.Background(), qr) require.NoError(t, err) require.NotNil(t, rsp) for k, v := range rsp.Responses { diff --git a/pkg/tsdb/testdatasource/resource_handler.go b/pkg/tsdb/testdatasource/resource_handler.go index 1a241aed663..2eb5ff1029d 100644 --- a/pkg/tsdb/testdatasource/resource_handler.go +++ b/pkg/tsdb/testdatasource/resource_handler.go @@ -15,40 +15,40 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" ) -func (p *TestDataPlugin) registerRoutes(mux *http.ServeMux) { - mux.HandleFunc("/", p.testGetHandler) - mux.HandleFunc("/scenarios", p.getScenariosHandler) - mux.HandleFunc("/stream", p.testStreamHandler) - mux.Handle("/test", createJSONHandler(p.logger)) - mux.Handle("/test/json", createJSONHandler(p.logger)) - mux.HandleFunc("/boom", p.testPanicHandler) +func (s *Service) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/", s.testGetHandler) + mux.HandleFunc("/scenarios", s.getScenariosHandler) + mux.HandleFunc("/stream", s.testStreamHandler) + mux.Handle("/test", createJSONHandler(s.logger)) + mux.Handle("/test/json", createJSONHandler(s.logger)) + mux.HandleFunc("/boom", s.testPanicHandler) } -func (p *TestDataPlugin) testGetHandler(rw http.ResponseWriter, req *http.Request) { - p.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) +func (s *Service) testGetHandler(rw http.ResponseWriter, req *http.Request) { + s.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) if req.Method != http.MethodGet { return } if _, err := rw.Write([]byte("Hello world from test datasource!")); err != nil { - p.logger.Error("Failed to write response", "error", err) + s.logger.Error("Failed to write response", "error", err) return } rw.WriteHeader(http.StatusOK) } -func (p *TestDataPlugin) getScenariosHandler(rw http.ResponseWriter, req *http.Request) { +func (s *Service) getScenariosHandler(rw http.ResponseWriter, req *http.Request) { result := make([]interface{}, 0) scenarioIds := make([]string, 0) - for id := range p.scenarios { + for id := range s.scenarios { scenarioIds = append(scenarioIds, id) } sort.Strings(scenarioIds) for _, scenarioID := range scenarioIds { - scenario := p.scenarios[scenarioID] + scenario := s.scenarios[scenarioID] result = append(result, map[string]interface{}{ "id": scenario.ID, "name": scenario.Name, @@ -59,18 +59,18 @@ func (p *TestDataPlugin) getScenariosHandler(rw http.ResponseWriter, req *http.R bytes, err := json.Marshal(&result) if err != nil { - p.logger.Error("Failed to marshal response body to JSON", "error", err) + s.logger.Error("Failed to marshal response body to JSON", "error", err) } rw.Header().Set("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) if _, err := rw.Write(bytes); err != nil { - p.logger.Error("Failed to write response", "error", err) + s.logger.Error("Failed to write response", "error", err) } } -func (p *TestDataPlugin) testStreamHandler(rw http.ResponseWriter, req *http.Request) { - p.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) +func (s *Service) testStreamHandler(rw http.ResponseWriter, req *http.Request) { + s.logger.Debug("Received resource call", "url", req.URL.String(), "method", req.Method) if req.Method != http.MethodGet { return @@ -95,7 +95,7 @@ func (p *TestDataPlugin) testStreamHandler(rw http.ResponseWriter, req *http.Req for i := 1; i <= count; i++ { if _, err := io.WriteString(rw, fmt.Sprintf("Message #%d", i)); err != nil { - p.logger.Error("Failed to write response", "error", err) + s.logger.Error("Failed to write response", "error", err) return } rw.(http.Flusher).Flush() @@ -152,6 +152,6 @@ func createJSONHandler(logger log.Logger) http.Handler { }) } -func (p *TestDataPlugin) testPanicHandler(rw http.ResponseWriter, req *http.Request) { +func (s *Service) testPanicHandler(rw http.ResponseWriter, req *http.Request) { panic("BOOM") } diff --git a/pkg/tsdb/testdatasource/scenarios.go b/pkg/tsdb/testdatasource/scenarios.go index b07e696ccb1..ab7885d90de 100644 --- a/pkg/tsdb/testdatasource/scenarios.go +++ b/pkg/tsdb/testdatasource/scenarios.go @@ -54,197 +54,197 @@ type Scenario struct { handler backend.QueryDataHandlerFunc } -func (p *TestDataPlugin) registerScenario(scenario *Scenario) { - p.scenarios[scenario.ID] = scenario - p.queryMux.HandleFunc(scenario.ID, scenario.handler) -} - -func (p *TestDataPlugin) registerScenarios() { - p.registerScenario(&Scenario{ +func (s *Service) registerScenarios() { + s.registerScenario(&Scenario{ ID: string(exponentialHeatmapBucketDataQuery), Name: "Exponential heatmap bucket data", - handler: p.handleExponentialHeatmapBucketDataScenario, + handler: s.handleExponentialHeatmapBucketDataScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(linearHeatmapBucketDataQuery), Name: "Linear heatmap bucket data", - handler: p.handleLinearHeatmapBucketDataScenario, + handler: s.handleLinearHeatmapBucketDataScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(randomWalkQuery), Name: "Random Walk", - handler: p.handleRandomWalkScenario, + handler: s.handleRandomWalkScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(predictablePulseQuery), Name: "Predictable Pulse", - handler: p.handlePredictablePulseScenario, + handler: s.handlePredictablePulseScenario, Description: `Predictable Pulse returns a pulse wave where there is a datapoint every timeStepSeconds. The wave cycles at timeStepSeconds*(onCount+offCount). The cycle of the wave is based off of absolute time (from the epoch) which makes it predictable. Timestamps will line up evenly on timeStepSeconds (For example, 60 seconds means times will all end in :00 seconds).`, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(predictableCSVWaveQuery), Name: "Predictable CSV Wave", - handler: p.handlePredictableCSVWaveScenario, + handler: s.handlePredictableCSVWaveScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(randomWalkTableQuery), Name: "Random Walk Table", - handler: p.handleRandomWalkTableScenario, + handler: s.handleRandomWalkTableScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(randomWalkSlowQuery), Name: "Slow Query", StringInput: "5s", - handler: p.handleRandomWalkSlowScenario, + handler: s.handleRandomWalkSlowScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(noDataPointsQuery), Name: "No Data Points", - handler: p.handleClientSideScenario, + handler: s.handleClientSideScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(datapointsOutsideRangeQuery), Name: "Datapoints Outside Range", - handler: p.handleDatapointsOutsideRangeScenario, + handler: s.handleDatapointsOutsideRangeScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(csvMetricValuesQuery), Name: "CSV Metric Values", StringInput: "1,20,90,30,5,0", - handler: p.handleCSVMetricValuesScenario, + handler: s.handleCSVMetricValuesScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(streamingClientQuery), Name: "Streaming Client", - handler: p.handleClientSideScenario, + handler: s.handleClientSideScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(liveQuery), Name: "Grafana Live", - handler: p.handleClientSideScenario, + handler: s.handleClientSideScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(flightPath), Name: "Flight path", - handler: p.handleFlightPathScenario, + handler: s.handleFlightPathScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(usaQueryKey), Name: "USA generated data", - handler: p.handleUSAScenario, + handler: s.handleUSAScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(grafanaAPIQuery), Name: "Grafana API", - handler: p.handleClientSideScenario, + handler: s.handleClientSideScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(arrowQuery), Name: "Load Apache Arrow Data", - handler: p.handleArrowScenario, + handler: s.handleArrowScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(annotationsQuery), Name: "Annotations", - handler: p.handleClientSideScenario, + handler: s.handleClientSideScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(tableStaticQuery), Name: "Table Static", - handler: p.handleTableStaticScenario, + handler: s.handleTableStaticScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(randomWalkWithErrorQuery), Name: "Random Walk (with error)", - handler: p.handleRandomWalkWithErrorScenario, + handler: s.handleRandomWalkWithErrorScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(serverError500Query), Name: "Server Error (500)", - handler: p.handleServerError500Scenario, + handler: s.handleServerError500Scenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(logsQuery), Name: "Logs", - handler: p.handleLogsScenario, + handler: s.handleLogsScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(nodeGraphQuery), Name: "Node Graph", }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(csvFileQueryType), Name: "CSV File", - handler: p.handleCsvFileScenario, + handler: s.handleCsvFileScenario, }) - p.registerScenario(&Scenario{ + s.registerScenario(&Scenario{ ID: string(csvContentQueryType), Name: "CSV Content", - handler: p.handleCsvContentScenario, + handler: s.handleCsvContentScenario, }) - p.queryMux.HandleFunc("", p.handleFallbackScenario) + s.queryMux.HandleFunc("", s.handleFallbackScenario) +} + +func (s *Service) registerScenario(scenario *Scenario) { + s.scenarios[scenario.ID] = scenario + s.queryMux.HandleFunc(scenario.ID, scenario.handler) } // handleFallbackScenario handles the scenario where queryType is not set and fallbacks to scenarioId. -func (p *TestDataPlugin) handleFallbackScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleFallbackScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { scenarioQueries := map[string][]backend.DataQuery{} for _, q := range req.Queries { model, err := simplejson.NewJson(q.JSON) if err != nil { - p.logger.Error("Failed to unmarshal query model to JSON", "error", err) + s.logger.Error("Failed to unmarshal query model to JSON", "error", err) continue } scenarioID := model.Get("scenarioId").MustString(string(randomWalkQuery)) - if _, exist := p.scenarios[scenarioID]; exist { + if _, exist := s.scenarios[scenarioID]; exist { if _, ok := scenarioQueries[scenarioID]; !ok { scenarioQueries[scenarioID] = []backend.DataQuery{} } scenarioQueries[scenarioID] = append(scenarioQueries[scenarioID], q) } else { - p.logger.Error("Scenario not found", "scenarioId", scenarioID) + s.logger.Error("Scenario not found", "scenarioId", scenarioID) } } resp := backend.NewQueryDataResponse() for scenarioID, queries := range scenarioQueries { - if scenario, exist := p.scenarios[scenarioID]; exist { + if scenario, exist := s.scenarios[scenarioID]; exist { sReq := &backend.QueryDataRequest{ PluginContext: req.PluginContext, Headers: req.Headers, Queries: queries, } if sResp, err := scenario.handler(ctx, sReq); err != nil { - p.logger.Error("Failed to handle scenario", "scenarioId", scenarioID, "error", err) + s.logger.Error("Failed to handle scenario", "scenarioId", scenarioID, "error", err) } else { for refID, dr := range sResp.Responses { resp.Responses[refID] = dr @@ -256,7 +256,7 @@ func (p *TestDataPlugin) handleFallbackScenario(ctx context.Context, req *backen return resp, nil } -func (p *TestDataPlugin) handleRandomWalkScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleRandomWalkScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -276,7 +276,7 @@ func (p *TestDataPlugin) handleRandomWalkScenario(ctx context.Context, req *back return resp, nil } -func (p *TestDataPlugin) handleDatapointsOutsideRangeScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleDatapointsOutsideRangeScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -300,7 +300,7 @@ func (p *TestDataPlugin) handleDatapointsOutsideRangeScenario(ctx context.Contex return resp, nil } -func (p *TestDataPlugin) handleCSVMetricValuesScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleCSVMetricValuesScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -344,7 +344,7 @@ func (p *TestDataPlugin) handleCSVMetricValuesScenario(ctx context.Context, req return resp, nil } -func (p *TestDataPlugin) handleRandomWalkWithErrorScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleRandomWalkWithErrorScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -362,7 +362,7 @@ func (p *TestDataPlugin) handleRandomWalkWithErrorScenario(ctx context.Context, return resp, nil } -func (p *TestDataPlugin) handleRandomWalkSlowScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleRandomWalkSlowScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -383,7 +383,7 @@ func (p *TestDataPlugin) handleRandomWalkSlowScenario(ctx context.Context, req * return resp, nil } -func (p *TestDataPlugin) handleRandomWalkTableScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleRandomWalkTableScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -400,7 +400,7 @@ func (p *TestDataPlugin) handleRandomWalkTableScenario(ctx context.Context, req return resp, nil } -func (p *TestDataPlugin) handlePredictableCSVWaveScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handlePredictableCSVWaveScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -421,7 +421,7 @@ func (p *TestDataPlugin) handlePredictableCSVWaveScenario(ctx context.Context, r return resp, nil } -func (p *TestDataPlugin) handlePredictablePulseScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handlePredictablePulseScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -442,15 +442,15 @@ func (p *TestDataPlugin) handlePredictablePulseScenario(ctx context.Context, req return resp, nil } -func (p *TestDataPlugin) handleServerError500Scenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleServerError500Scenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { panic("Test Data Panic!") } -func (p *TestDataPlugin) handleClientSideScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleClientSideScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { return backend.NewQueryDataResponse(), nil } -func (p *TestDataPlugin) handleArrowScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleArrowScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -474,7 +474,7 @@ func (p *TestDataPlugin) handleArrowScenario(ctx context.Context, req *backend.Q return resp, nil } -func (p *TestDataPlugin) handleExponentialHeatmapBucketDataScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleExponentialHeatmapBucketDataScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -489,7 +489,7 @@ func (p *TestDataPlugin) handleExponentialHeatmapBucketDataScenario(ctx context. return resp, nil } -func (p *TestDataPlugin) handleLinearHeatmapBucketDataScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleLinearHeatmapBucketDataScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -504,7 +504,7 @@ func (p *TestDataPlugin) handleLinearHeatmapBucketDataScenario(ctx context.Conte return resp, nil } -func (p *TestDataPlugin) handleTableStaticScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleTableStaticScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { @@ -533,7 +533,7 @@ func (p *TestDataPlugin) handleTableStaticScenario(ctx context.Context, req *bac return resp, nil } -func (p *TestDataPlugin) handleLogsScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleLogsScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { diff --git a/pkg/tsdb/testdatasource/scenarios_test.go b/pkg/tsdb/testdatasource/scenarios_test.go index 8cbcd71bc24..ba80059eb0a 100644 --- a/pkg/tsdb/testdatasource/scenarios_test.go +++ b/pkg/tsdb/testdatasource/scenarios_test.go @@ -15,7 +15,7 @@ import ( ) func TestTestdataScenarios(t *testing.T) { - p := &TestDataPlugin{} + s := &Service{} t.Run("random walk ", func(t *testing.T) { t.Run("Should start at the requested value", func(t *testing.T) { @@ -42,7 +42,7 @@ func TestTestdataScenarios(t *testing.T) { Queries: []backend.DataQuery{query}, } - resp, err := p.handleRandomWalkScenario(context.Background(), req) + resp, err := s.handleRandomWalkScenario(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) @@ -85,7 +85,7 @@ func TestTestdataScenarios(t *testing.T) { Queries: []backend.DataQuery{query}, } - resp, err := p.handleRandomWalkTableScenario(context.Background(), req) + resp, err := s.handleRandomWalkTableScenario(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) @@ -141,7 +141,7 @@ func TestTestdataScenarios(t *testing.T) { Queries: []backend.DataQuery{query}, } - resp, err := p.handleRandomWalkTableScenario(context.Background(), req) + resp, err := s.handleRandomWalkTableScenario(context.Background(), req) require.NoError(t, err) require.NotNil(t, resp) diff --git a/pkg/tsdb/testdatasource/stream_handler.go b/pkg/tsdb/testdatasource/stream_handler.go index 5c2df6a95e7..2a5d4331872 100644 --- a/pkg/tsdb/testdatasource/stream_handler.go +++ b/pkg/tsdb/testdatasource/stream_handler.go @@ -9,34 +9,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" - - "github.com/grafana/grafana/pkg/infra/log" ) -type testStreamHandler struct { - logger log.Logger - frame *data.Frame - // If Live Pipeline enabled we are sending the whole frame to have a chance to process stream with rules. - livePipelineEnabled bool -} - -func newTestStreamHandler(logger log.Logger, livePipelineEnabled bool) *testStreamHandler { - frame := data.NewFrame("testdata", - data.NewField("Time", nil, make([]time.Time, 1)), - data.NewField("Value", nil, make([]float64, 1)), - data.NewField("Min", nil, make([]float64, 1)), - data.NewField("Max", nil, make([]float64, 1)), - ) - return &testStreamHandler{ - frame: frame, - logger: logger, - livePipelineEnabled: livePipelineEnabled, - } -} - -func (p *testStreamHandler) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { - p.logger.Debug("Allowing access to stream", "path", req.Path, "user", req.PluginContext.User) - initialData, err := backend.NewInitialFrame(p.frame, data.IncludeSchemaOnly) +func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) { + s.logger.Debug("Allowing access to stream", "path", req.Path, "user", req.PluginContext.User) + initialData, err := backend.NewInitialFrame(s.frame, data.IncludeSchemaOnly) if err != nil { return nil, err } @@ -50,7 +27,7 @@ func (p *testStreamHandler) SubscribeStream(_ context.Context, req *backend.Subs } } - if p.livePipelineEnabled { + if s.cfg.FeatureToggles["live-pipeline"] { // While developing Live pipeline avoid sending initial data. initialData = nil } @@ -61,15 +38,15 @@ func (p *testStreamHandler) SubscribeStream(_ context.Context, req *backend.Subs }, nil } -func (p *testStreamHandler) PublishStream(_ context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { - p.logger.Debug("Attempt to publish into stream", "path", req.Path, "user", req.PluginContext.User) +func (s *Service) PublishStream(_ context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) { + s.logger.Debug("Attempt to publish into stream", "path", req.Path, "user", req.PluginContext.User) return &backend.PublishStreamResponse{ Status: backend.PublishStreamStatusPermissionDenied, }, nil } -func (p *testStreamHandler) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error { - p.logger.Debug("New stream call", "path", request.Path) +func (s *Service) RunStream(ctx context.Context, request *backend.RunStreamRequest, sender *backend.StreamSender) error { + s.logger.Debug("New stream call", "path", request.Path) var conf testStreamConfig switch request.Path { case "random-2s-stream": @@ -93,7 +70,7 @@ func (p *testStreamHandler) RunStream(ctx context.Context, request *backend.RunS default: return fmt.Errorf("testdata plugin does not support path: %s", request.Path) } - return p.runTestStream(ctx, request.Path, conf, sender) + return s.runTestStream(ctx, request.Path, conf, sender) } type testStreamConfig struct { @@ -102,7 +79,7 @@ type testStreamConfig struct { Flight *flightConfig } -func (p *testStreamHandler) runTestStream(ctx context.Context, path string, conf testStreamConfig, sender *backend.StreamSender) error { +func (s *Service) runTestStream(ctx context.Context, path string, conf testStreamConfig, sender *backend.StreamSender) error { spread := 50.0 walker := rand.Float64() * 100 @@ -118,7 +95,7 @@ func (p *testStreamHandler) runTestStream(ctx context.Context, path string, conf for { select { case <-ctx.Done(): - p.logger.Debug("Stop streaming data for path", "path", path) + s.logger.Debug("Stop streaming data for path", "path", path) return ctx.Err() case t := <-ticker.C: if rand.Float64() < conf.Drop { @@ -126,7 +103,7 @@ func (p *testStreamHandler) runTestStream(ctx context.Context, path string, conf } mode := data.IncludeDataOnly - if p.livePipelineEnabled { + if s.cfg.FeatureToggles["live-pipeline"] { mode = data.IncludeAll } @@ -139,11 +116,11 @@ func (p *testStreamHandler) runTestStream(ctx context.Context, path string, conf delta := rand.Float64() - 0.5 walker += delta - p.frame.Fields[0].Set(0, t) - p.frame.Fields[1].Set(0, walker) // Value - p.frame.Fields[2].Set(0, walker-((rand.Float64()*spread)+0.01)) // Min - p.frame.Fields[3].Set(0, walker+((rand.Float64()*spread)+0.01)) // Max - if err := sender.SendFrame(p.frame, mode); err != nil { + s.frame.Fields[0].Set(0, t) + s.frame.Fields[1].Set(0, walker) // Value + s.frame.Fields[2].Set(0, walker-((rand.Float64()*spread)+0.01)) // Min + s.frame.Fields[3].Set(0, walker+((rand.Float64()*spread)+0.01)) // Max + if err := sender.SendFrame(s.frame, mode); err != nil { return err } } diff --git a/pkg/tsdb/testdatasource/testdata.go b/pkg/tsdb/testdatasource/testdata.go index 60506f8a479..6a90d2cf0b3 100644 --- a/pkg/tsdb/testdatasource/testdata.go +++ b/pkg/tsdb/testdatasource/testdata.go @@ -2,49 +2,54 @@ package testdatasource import ( "net/http" + "time" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" + "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, manager backendplugin.Manager) (*TestDataPlugin, error) { - resourceMux := http.NewServeMux() - p := new(cfg, resourceMux) +func ProvideService(cfg *setting.Cfg, registrar plugins.CoreBackendRegistrar) (*Service, error) { + s := &Service{ + queryMux: datasource.NewQueryTypeMux(), + scenarios: map[string]*Scenario{}, + frame: data.NewFrame("testdata", + data.NewField("Time", nil, make([]time.Time, 1)), + data.NewField("Value", nil, make([]float64, 1)), + data.NewField("Min", nil, make([]float64, 1)), + data.NewField("Max", nil, make([]float64, 1)), + ), + logger: log.New("tsdb.testdata"), + cfg: cfg, + } + + s.registerScenarios() + + rMux := http.NewServeMux() + s.RegisterRoutes(rMux) + factory := coreplugin.New(backend.ServeOpts{ - QueryDataHandler: p.queryMux, - CallResourceHandler: httpadapter.New(resourceMux), - StreamHandler: newTestStreamHandler(p.logger, cfg.FeatureToggles["live-pipeline"]), + QueryDataHandler: s.queryMux, + CallResourceHandler: httpadapter.New(rMux), + StreamHandler: s, }) - err := manager.Register("testdata", factory) + err := registrar.LoadAndRegister("testdata", factory) if err != nil { return nil, err } - return p, nil + return s, nil } -func new(cfg *setting.Cfg, resourceMux *http.ServeMux) *TestDataPlugin { - p := &TestDataPlugin{ - logger: log.New("tsdb.testdata"), - cfg: cfg, - scenarios: map[string]*Scenario{}, - queryMux: datasource.NewQueryTypeMux(), - } - - p.registerScenarios() - p.registerRoutes(resourceMux) - - return p -} - -type TestDataPlugin struct { +type Service struct { cfg *setting.Cfg logger log.Logger scenarios map[string]*Scenario + frame *data.Frame queryMux *datasource.QueryTypeMux } diff --git a/pkg/tsdb/testdatasource/usa_stats.go b/pkg/tsdb/testdatasource/usa_stats.go index 1c0d17d805d..5d88b02ea76 100644 --- a/pkg/tsdb/testdatasource/usa_stats.go +++ b/pkg/tsdb/testdatasource/usa_stats.go @@ -36,7 +36,7 @@ type usaQuery struct { period time.Duration } -func (p *TestDataPlugin) handleUSAScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { +func (s *Service) handleUSAScenario(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { resp := backend.NewQueryDataResponse() for _, q := range req.Queries { diff --git a/pkg/tsdb/testdatasource/usa_stats_test.go b/pkg/tsdb/testdatasource/usa_stats_test.go index 034d9b8ee38..134e38bd29c 100644 --- a/pkg/tsdb/testdatasource/usa_stats_test.go +++ b/pkg/tsdb/testdatasource/usa_stats_test.go @@ -17,7 +17,7 @@ import ( func TestUSAScenario(t *testing.T) { cfg := setting.NewCfg() - p := &TestDataPlugin{ + p := &Service{ cfg: cfg, } diff --git a/public/app/features/plugins/admin/components/Badges/PluginUpdateAvailableBadge.tsx b/public/app/features/plugins/admin/components/Badges/PluginUpdateAvailableBadge.tsx index de1d244ca2b..3078c73d0b1 100644 --- a/public/app/features/plugins/admin/components/Badges/PluginUpdateAvailableBadge.tsx +++ b/public/app/features/plugins/admin/components/Badges/PluginUpdateAvailableBadge.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { css } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, PluginType } from '@grafana/data'; import { Tooltip, useStyles2 } from '@grafana/ui'; import { CatalogPlugin } from '../../types'; @@ -11,7 +11,7 @@ type Props = { export function PluginUpdateAvailableBadge({ plugin }: Props): React.ReactElement | null { const styles = useStyles2(getStyles); - if (plugin.hasUpdate && !plugin.isCore) { + if (plugin.hasUpdate && !plugin.isCore && plugin.type !== PluginType.renderer) { return (

Update available!

diff --git a/public/app/features/plugins/admin/components/InstallControls/index.tsx b/public/app/features/plugins/admin/components/InstallControls/index.tsx index 689a0b4a586..d8435704b96 100644 --- a/public/app/features/plugins/admin/components/InstallControls/index.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/index.tsx @@ -4,7 +4,7 @@ import { satisfies } from 'semver'; import { config } from '@grafana/runtime'; import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, PluginType } from '@grafana/data'; import { ExternallyManagedButton } from './ExternallyManagedButton'; import { InstallControlsButton } from './InstallControlsButton'; @@ -35,7 +35,7 @@ export const InstallControls = ({ plugin }: Props) => { : PluginStatus.UNINSTALL : PluginStatus.INSTALL; - if (plugin.isCore || plugin.isDisabled) { + if (plugin.isCore || plugin.isDisabled || plugin.type === PluginType.renderer) { return null; } diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index 5bd2424213b..9a3186b2bba 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -8,7 +8,7 @@ import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock, mockUserPerm import { configureStore } from 'app/store/configureStore'; import PluginDetailsPage from './PluginDetails'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; -import { CatalogPlugin, PluginTabIds, RequestStatus, ReducerState } from '../types'; +import { CatalogPlugin, PluginTabIds, ReducerState, RequestStatus } from '../types'; import * as api from '../api'; import { fetchRemotePlugins } from '../state/actions'; import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data'; @@ -263,6 +263,13 @@ describe('Plugin details page', () => { await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument()); }); + it('should not display install / uninstall buttons for renderer plugins', async () => { + const { queryByRole } = renderPluginDetails({ id, type: PluginType.renderer }); + + await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument()); + await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument()); + }); + it('should display install link with `config.pluginAdminExternalManageEnabled` set to true', async () => { config.pluginAdminExternalManageEnabled = true; diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 9cac2078867..9a4182ae060 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -29,7 +29,7 @@ export enum PluginIconName { app = 'apps', datasource = 'database', panel = 'credit-card', - renderer = 'pen', + renderer = 'capture', } export interface CatalogPlugin { diff --git a/public/img/icn-renderer.svg b/public/img/icn-renderer.svg new file mode 100644 index 00000000000..5b4a128ec14 --- /dev/null +++ b/public/img/icn-renderer.svg @@ -0,0 +1,3 @@ + + +