diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 4d7e6ed1201..f7b3fa21399 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -579,6 +579,7 @@ export { PluginSignatureType, PluginErrorCode, PluginIncludeType, + PluginLoadingStrategy, GrafanaPlugin, type PluginError, type AngularMeta, diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index c5632e5d69b..56ec6168d0b 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -59,6 +59,12 @@ export interface AngularMeta { hideDeprecation: boolean; } +// Signals to SystemJS how to load frontend js assets. +export enum PluginLoadingStrategy { + fetch = 'fetch', + script = 'script', +} + export interface PluginMeta { id: string; name: string; @@ -91,6 +97,7 @@ export interface PluginMeta { live?: boolean; angular?: AngularMeta; angularDetected?: boolean; + loadingStrategy?: PluginLoadingStrategy; } interface PluginDependencyInfo { diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index f92f4023bf0..8c1c1c93d73 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -17,6 +17,7 @@ import { SystemDateFormatSettings, getThemeById, AngularMeta, + PluginLoadingStrategy, } from '@grafana/data'; export interface AzureSettings { @@ -40,6 +41,7 @@ export type AppPluginConfig = { version: string; preload: boolean; angular: AngularMeta; + loadingStrategy: PluginLoadingStrategy; }; export type PreinstalledPlugin = { diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 7d768e059e4..0d882bce28f 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -28,6 +28,7 @@ type PluginSetting struct { SignatureType plugins.SignatureType `json:"signatureType"` SignatureOrg string `json:"signatureOrg"` AngularDetected bool `json:"angularDetected"` + LoadingStrategy plugins.LoadingStrategy `json:"loadingStrategy"` } type PluginListItem struct { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 677e8fa98aa..7d50bd47140 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -110,7 +110,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro apps := make(map[string]*plugins.AppDTO, 0) for _, ap := range availablePlugins[plugins.TypeApp] { - apps[ap.Plugin.ID] = newAppDTO( + apps[ap.Plugin.ID] = hs.newAppDTO( + c.Req.Context(), ap.Plugin, ap.Settings, ) @@ -140,18 +141,19 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro } panels[panel.ID] = plugins.PanelDTO{ - ID: panel.ID, - Name: panel.Name, - AliasIDs: panel.AliasIDs, - Info: panel.Info, - Module: panel.Module, - BaseURL: panel.BaseURL, - SkipDataQuery: panel.SkipDataQuery, - HideFromList: panel.HideFromList, - ReleaseState: string(panel.State), - Signature: string(panel.Signature), - Sort: getPanelSort(panel.ID), - Angular: panel.Angular, + ID: panel.ID, + Name: panel.Name, + AliasIDs: panel.AliasIDs, + Info: panel.Info, + Module: panel.Module, + BaseURL: panel.BaseURL, + SkipDataQuery: panel.SkipDataQuery, + HideFromList: panel.HideFromList, + ReleaseState: string(panel.State), + Signature: string(panel.Signature), + Sort: getPanelSort(panel.ID), + Angular: panel.Angular, + LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), panel), } } @@ -455,6 +457,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug BaseURL: plugin.BaseURL, Angular: plugin.Angular, MultiValueFilterOperators: plugin.MultiValueFilterOperators, + LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin), } if ds.JsonData == nil { @@ -551,13 +554,14 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug return dataSources, nil } -func newAppDTO(plugin pluginstore.Plugin, settings pluginsettings.InfoDTO) *plugins.AppDTO { +func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, settings pluginsettings.InfoDTO) *plugins.AppDTO { app := &plugins.AppDTO{ - ID: plugin.ID, - Version: plugin.Info.Version, - Path: plugin.Module, - Preload: false, - Angular: plugin.Angular, + ID: plugin.ID, + Version: plugin.Info.Version, + Path: plugin.Module, + Preload: false, + Angular: plugin.Angular, + LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin), } if settings.Enabled { diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index ad5a753d7a7..6d7139e28db 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/rendering" @@ -35,7 +36,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service) (*web.Mux, *HTTPServer) { +func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service, passets *pluginassets.Service) (*web.Mux, *HTTPServer) { t.Helper() db.InitTestDB(t) // nolint:staticcheck @@ -50,6 +51,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F }) } + pluginsCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{ + PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, + PluginSettings: cfg.PluginSettings, + }) + var pluginStore = pstore if pluginStore == nil { pluginStore = &pluginstore.FakePluginStore{} @@ -60,6 +66,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F pluginsSettings = &pluginsettings.FakePluginSettings{} } + var pluginsAssets = passets + if pluginsAssets == nil { + pluginsAssets = pluginassets.ProvideService(cfg, pluginsCDN) + } + hs := &HTTPServer{ authnService: &authntest.FakeService{}, Cfg: cfg, @@ -69,16 +80,14 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F Cfg: cfg, RendererPluginManager: &fakeRendererPluginManager{}, }, - SQLStore: db.InitTestDB(t), - SettingsProvider: setting.ProvideProvider(cfg), - pluginStore: pluginStore, - grafanaUpdateChecker: &updatechecker.GrafanaService{}, - AccessControl: accesscontrolmock.New(), - PluginSettings: pluginsSettings, - pluginsCDNService: pluginscdn.ProvideService(&config.PluginManagementCfg{ - PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, - PluginSettings: cfg.PluginSettings, - }), + SQLStore: db.InitTestDB(t), + SettingsProvider: setting.ProvideProvider(cfg), + pluginStore: pluginStore, + grafanaUpdateChecker: &updatechecker.GrafanaService{}, + AccessControl: accesscontrolmock.New(), + PluginSettings: pluginsSettings, + pluginsCDNService: pluginsCDN, + pluginAssets: pluginsAssets, namespacer: request.GetNamespaceMapper(cfg), SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, &ssosettingstests.MockService{}), managedPluginsService: managedplugins.NewNoop(), @@ -108,7 +117,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { cfg.BuildVersion = "7.8.9" cfg.BuildCommit = "01234567" - m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil) + m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) @@ -198,7 +207,7 @@ func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) { if test.mutateCfg != nil { test.mutateCfg(cfg) } - m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil) + m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) recorder := httptest.NewRecorder() @@ -221,6 +230,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { desc string pluginStore func() pluginstore.Store pluginSettings func() pluginsettings.Service + pluginAssets func() *pluginassets.Service expected settings }{ { @@ -245,13 +255,17 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", false), } }, + pluginAssets: func() *pluginassets.Service { + return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) + }, expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { - ID: "test-app", - Preload: false, - Path: "/test-app/module.js", - Version: "0.5.0", + ID: "test-app", + Preload: false, + Path: "/test-app/module.js", + Version: "0.5.0", + LoadingStrategy: plugins.LoadingStrategyScript, }, }, }, @@ -278,13 +292,17 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", true), } }, + pluginAssets: func() *pluginassets.Service { + return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) + }, expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { - ID: "test-app", - Preload: true, - Path: "/test-app/module.js", - Version: "0.5.0", + ID: "test-app", + Preload: true, + Path: "/test-app/module.js", + Version: "0.5.0", + LoadingStrategy: plugins.LoadingStrategyScript, }, }, }, @@ -312,14 +330,99 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { Plugins: newAppSettings("test-app", true), } }, + pluginAssets: func() *pluginassets.Service { + return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) + }, + expected: settings{ + Apps: map[string]*plugins.AppDTO{ + "test-app": { + ID: "test-app", + Preload: true, + Path: "/test-app/module.js", + Version: "0.5.0", + Angular: plugins.AngularMeta{Detected: true}, + LoadingStrategy: plugins.LoadingStrategyFetch, + }, + }, + }, + }, + { + desc: "app plugin with create plugin version compatible with script loading strategy", + pluginStore: func() pluginstore.Store { + return &pluginstore.FakePluginStore{ + PluginList: []pluginstore.Plugin{ + { + Module: fmt.Sprintf("/%s/module.js", "test-app"), + JSONData: plugins.JSONData{ + ID: "test-app", + Info: plugins.Info{Version: "0.5.0"}, + Type: plugins.TypeApp, + Preload: true, + }, + }, + }, + } + }, + pluginSettings: func() pluginsettings.Service { + return &pluginsettings.FakePluginSettings{ + Plugins: newAppSettings("test-app", true), + } + }, + pluginAssets: func() *pluginassets.Service { + return pluginassets.ProvideService(&setting.Cfg{ + PluginSettings: map[string]map[string]string{ + "test-app": { + pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled, + }, + }, + }, pluginscdn.ProvideService(&config.PluginManagementCfg{})) + }, + expected: settings{ + Apps: map[string]*plugins.AppDTO{ + "test-app": { + ID: "test-app", + Preload: true, + Path: "/test-app/module.js", + Version: "0.5.0", + LoadingStrategy: plugins.LoadingStrategyScript, + }, + }, + }, + }, + { + desc: "app plugin with CDN class", + pluginStore: func() pluginstore.Store { + return &pluginstore.FakePluginStore{ + PluginList: []pluginstore.Plugin{ + { + Class: plugins.ClassCDN, + Module: fmt.Sprintf("/%s/module.js", "test-app"), + JSONData: plugins.JSONData{ + ID: "test-app", + Info: plugins.Info{Version: "0.5.0"}, + Type: plugins.TypeApp, + Preload: true, + }, + }, + }, + } + }, + pluginSettings: func() pluginsettings.Service { + return &pluginsettings.FakePluginSettings{ + Plugins: newAppSettings("test-app", true), + } + }, + pluginAssets: func() *pluginassets.Service { + return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{})) + }, expected: settings{ Apps: map[string]*plugins.AppDTO{ "test-app": { - ID: "test-app", - Preload: true, - Path: "/test-app/module.js", - Version: "0.5.0", - Angular: plugins.AngularMeta{Detected: true}, + ID: "test-app", + Preload: true, + Path: "/test-app/module.js", + Version: "0.5.0", + LoadingStrategy: plugins.LoadingStrategyFetch, }, }, }, @@ -329,7 +432,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) { for _, test := range tests { t.Run(test.desc, func(t *testing.T) { cfg := setting.NewCfg() - m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings()) + m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings(), test.pluginAssets()) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) recorder := httptest.NewRecorder() diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 6f1295aa48f..fa13e721a94 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -78,6 +78,7 @@ import ( "github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -146,6 +147,7 @@ type HTTPServer struct { pluginDashboardService plugindashboards.Service pluginStaticRouteResolver plugins.StaticRouteResolver pluginErrorResolver plugins.ErrorResolver + pluginAssets *pluginassets.Service SearchService search.Service ShortURLService shorturls.Service QueryHistoryService queryhistory.Service @@ -247,7 +249,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService, pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore, - serviceaccountsService serviceaccounts.Service, + serviceaccountsService serviceaccounts.Service, pluginAssets *pluginassets.Service, authInfoService login.AuthInfoService, storageService store.StorageService, notificationService notifications.Service, dashboardService dashboards.DashboardService, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service, @@ -286,6 +288,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi pluginStore: pluginStore, pluginStaticRouteResolver: pluginStaticRouteResolver, pluginDashboardService: pluginDashboardService, + pluginAssets: pluginAssets, pluginErrorResolver: pluginErrorResolver, pluginFileStore: pluginFileStore, grafanaUpdateChecker: grafanaUpdateChecker, diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index d1793e727e8..256c50d5f6b 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -208,6 +208,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. SignatureOrg: plugin.SignatureOrg, SecureJsonFields: map[string]bool{}, AngularDetected: plugin.Angular.Detected, + LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin), } if plugin.IsApp() { diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index fd1ad0df80f..b517c964f84 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -41,6 +41,7 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -817,6 +818,7 @@ func Test_PluginsSettings(t *testing.T) { Version: "1.0.0", }, SecureJsonFields: map[string]bool{}, + LoadingStrategy: plugins.LoadingStrategyScript, }, }, { @@ -841,6 +843,8 @@ func Test_PluginsSettings(t *testing.T) { ErrorCode: tc.errCode, }) } + pluginCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{}) + hs.pluginAssets = pluginassets.ProvideService(hs.Cfg, pluginCDN) hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker) var err error hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest()) diff --git a/pkg/plugins/manager/loader/assetpath/assetpath.go b/pkg/plugins/manager/loader/assetpath/assetpath.go index e26ce3bae1d..873e21feb60 100644 --- a/pkg/plugins/manager/loader/assetpath/assetpath.go +++ b/pkg/plugins/manager/loader/assetpath/assetpath.go @@ -57,13 +57,14 @@ func (s *Service) Base(n PluginInfo) (string, error) { return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "") } if n.parent != nil { + relPath, err := n.parent.fs.Rel(n.fs.Base()) + if err != nil { + return "", err + } if s.cdn.PluginSupported(n.parent.pluginJSON.ID) { - relPath, err := n.parent.fs.Rel(n.fs.Base()) - if err != nil { - return "", err - } return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, relPath) } + return path.Join("public/plugins", n.parent.pluginJSON.ID, relPath), nil } return path.Join("public/plugins", n.pluginJSON.ID), nil @@ -87,13 +88,14 @@ func (s *Service) Module(n PluginInfo) (string, error) { return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "module.js") } if n.parent != nil { + relPath, err := n.parent.fs.Rel(n.fs.Base()) + if err != nil { + return "", err + } if s.cdn.PluginSupported(n.parent.pluginJSON.ID) { - relPath, err := n.parent.fs.Rel(n.fs.Base()) - if err != nil { - return "", err - } return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, path.Join(relPath, "module.js")) } + return path.Join("public/plugins", n.parent.pluginJSON.ID, relPath, "module.js"), nil } return path.Join("public/plugins", n.pluginJSON.ID, "module.js"), nil @@ -117,10 +119,6 @@ func (s *Service) RelativeURL(n PluginInfo, pathStr string) (string, error) { return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, path.Join(relPath, pathStr)) } } - - if s.cdn.PluginSupported(n.pluginJSON.ID) { - return s.cdn.NewCDNURLConstructor(n.pluginJSON.ID, n.pluginJSON.Info.Version).StringPath(pathStr) - } // Local u, err := url.Parse(pathStr) if err != nil { diff --git a/pkg/plugins/manager/loader/assetpath/assetpath_test.go b/pkg/plugins/manager/loader/assetpath/assetpath_test.go index be8ce730b95..04c22b1a478 100644 --- a/pkg/plugins/manager/loader/assetpath/assetpath_test.go +++ b/pkg/plugins/manager/loader/assetpath/assetpath_test.go @@ -187,3 +187,153 @@ func TestService(t *testing.T) { }) } } + +func TestService_ChildPlugins(t *testing.T) { + type expected struct { + module string + baseURL string + relURL string + } + + tcs := []struct { + name string + cfg *config.PluginManagementCfg + pluginInfo func() PluginInfo + expected expected + }{ + { + name: "Local FS external plugin", + cfg: &config.PluginManagementCfg{}, + pluginInfo: func() PluginInfo { + return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil) + }, + expected: expected{ + module: "public/plugins/parent/module.js", + baseURL: "public/plugins/parent", + relURL: "public/plugins/parent/path/to/file.txt", + }, + }, + { + name: "Local FS external plugin with child", + cfg: &config.PluginManagementCfg{}, + pluginInfo: func() PluginInfo { + parentInfo := NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil) + childInfo := NewPluginInfo(plugins.JSONData{ID: "child", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent/child"), &parentInfo) + return childInfo + }, + expected: expected{ + module: "public/plugins/parent/child/module.js", + baseURL: "public/plugins/parent/child", + relURL: "public/plugins/parent/child/path/to/file.txt", + }, + }, + { + name: "Local FS core plugin", + cfg: &config.PluginManagementCfg{}, + pluginInfo: func() PluginInfo { + return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCore, plugins.NewLocalFS("/plugins/parent"), nil) + }, + expected: expected{ + module: "core:plugin/parent", + baseURL: "public/app/plugins/parent", + relURL: "public/app/plugins/parent/path/to/file.txt", + }, + }, + { + name: "Externally-built Local FS core plugin", + cfg: &config.PluginManagementCfg{}, + pluginInfo: func() PluginInfo { + return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCore, plugins.NewLocalFS("/plugins/parent/dist"), nil) + }, + expected: expected{ + module: "public/plugins/parent/module.js", + baseURL: "public/app/plugins/parent", + relURL: "public/app/plugins/parent/path/to/file.txt", + }, + }, + { + name: "CDN Class plugin", + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + }, + pluginInfo: func() PluginInfo { + return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCDN, pluginFS("https://cdn.example.com/plugins/parent"), nil) + }, + expected: expected{ + module: "https://cdn.example.com/plugins/parent/module.js", + baseURL: "https://cdn.example.com/plugins/parent", + relURL: "https://cdn.example.com/plugins/parent/path/to/file.txt", + }, + }, + { + name: "CDN Class plugin with child", + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + }, + pluginInfo: func() PluginInfo { + // Note: fake plugin FS is the most convenient way to mock the plugin FS for CDN plugins + parentInfo := NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCDN, pluginFS("https://cdn.example.com/parent"), nil) + childInfo := NewPluginInfo(plugins.JSONData{ID: "child", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCDN, pluginFS("https://cdn.example.com/parent/some/other/dir/child"), &parentInfo) + return childInfo + }, + expected: expected{ + module: "https://cdn.example.com/parent/some/other/dir/child/module.js", + baseURL: "https://cdn.example.com/parent/some/other/dir/child", + relURL: "https://cdn.example.com/parent/some/other/dir/child/path/to/file.txt", + }, + }, + { + name: "CDN supported plugin", + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + PluginSettings: map[string]map[string]string{ + "parent": {"cdn": "true"}, + }, + }, + pluginInfo: func() PluginInfo { + return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil) + }, + expected: expected{ + module: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/module.js", + baseURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent", + relURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/path/to/file.txt", + }, + }, + { + name: "CDN supported plugin with child", + cfg: &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.example.com", + PluginSettings: map[string]map[string]string{ + "parent": {"cdn": "true"}, + }, + }, + pluginInfo: func() PluginInfo { + parentInfo := NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil) + childInfo := NewPluginInfo(plugins.JSONData{ID: "child", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent/child"), &parentInfo) + return childInfo + }, + expected: expected{ + module: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/child/module.js", + baseURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/child", + relURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/child/path/to/file.txt", + }, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + svc := ProvideService(tc.cfg, pluginscdn.ProvideService(tc.cfg)) + + module, err := svc.Module(tc.pluginInfo()) + require.NoError(t, err) + require.Equal(t, tc.expected.module, module) + + baseURL, err := svc.Base(tc.pluginInfo()) + require.NoError(t, err) + require.Equal(t, tc.expected.baseURL, baseURL) + + relURL, err := svc.RelativeURL(tc.pluginInfo(), "path/to/file.txt") + require.NoError(t, err) + require.Equal(t, tc.expected.relURL, relURL) + }) + } +} diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index f08b192062c..af96b578da4 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -176,6 +176,7 @@ type PluginMetaDTO struct { BaseURL string `json:"baseUrl"` Angular AngularMeta `json:"angular"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"` + LoadingStrategy LoadingStrategy `json:"loadingStrategy"` } type DataSourceDTO struct { @@ -211,28 +212,28 @@ type DataSourceDTO struct { } type PanelDTO struct { - ID string `json:"id"` - Name string `json:"name"` - AliasIDs []string `json:"aliasIds,omitempty"` - Info Info `json:"info"` - HideFromList bool `json:"hideFromList"` - Sort int `json:"sort"` - SkipDataQuery bool `json:"skipDataQuery"` - ReleaseState string `json:"state"` - BaseURL string `json:"baseUrl"` - Signature string `json:"signature"` - Module string `json:"module"` - - Angular AngularMeta `json:"angular"` + ID string `json:"id"` + Name string `json:"name"` + AliasIDs []string `json:"aliasIds,omitempty"` + Info Info `json:"info"` + HideFromList bool `json:"hideFromList"` + Sort int `json:"sort"` + SkipDataQuery bool `json:"skipDataQuery"` + ReleaseState string `json:"state"` + BaseURL string `json:"baseUrl"` + Signature string `json:"signature"` + Module string `json:"module"` + Angular AngularMeta `json:"angular"` + LoadingStrategy LoadingStrategy `json:"loadingStrategy"` } type AppDTO struct { - ID string `json:"id"` - Path string `json:"path"` - Version string `json:"version"` - Preload bool `json:"preload"` - - Angular AngularMeta `json:"angular"` + ID string `json:"id"` + Path string `json:"path"` + Version string `json:"version"` + Preload bool `json:"preload"` + Angular AngularMeta `json:"angular"` + LoadingStrategy LoadingStrategy `json:"loadingStrategy"` } const ( @@ -252,6 +253,13 @@ type Error struct { message string `json:"-"` } +type LoadingStrategy string + +const ( + LoadingStrategyFetch LoadingStrategy = "fetch" + LoadingStrategyScript LoadingStrategy = "script" +) + func (e Error) Error() string { if e.message != "" { return e.message diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 411825ff306..2fed29e25a4 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -1243,8 +1243,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Plugins: []plugins.Dependency{}, }, }, - Module: "public/plugins/test-panel/module.js", - BaseURL: "public/plugins/test-panel", + Module: "public/plugins/test-datasource/nested/module.js", + BaseURL: "public/plugins/test-datasource/nested", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), Signature: plugins.SignatureStatusValid, SignatureType: plugins.SignatureTypeGrafana, @@ -1408,8 +1408,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { {Name: "License", URL: "https://github.com/grafana/grafana-starter-panel/blob/master/LICENSE"}, }, Logos: plugins.Logos{ - Small: "public/plugins/myorgid-simple-panel/img/logo.svg", - Large: "public/plugins/myorgid-simple-panel/img/logo.svg", + Small: "public/plugins/myorgid-simple-app/child/img/logo.svg", + Large: "public/plugins/myorgid-simple-app/child/img/logo.svg", }, Screenshots: []plugins.Screenshots{}, Description: "Grafana Panel Plugin Template", @@ -1423,8 +1423,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) { Plugins: []plugins.Dependency{}, }, }, - Module: "public/plugins/myorgid-simple-panel/module.js", - BaseURL: "public/plugins/myorgid-simple-panel", + Module: "public/plugins/myorgid-simple-app/child/module.js", + BaseURL: "public/plugins/myorgid-simple-app/child", FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")), IncludedInAppID: parent.ID, Signature: plugins.SignatureStatusValid, diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets.go b/pkg/services/pluginsintegration/pluginassets/pluginassets.go new file mode 100644 index 00000000000..a92aabecb6f --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets.go @@ -0,0 +1,81 @@ +package pluginassets + +import ( + "context" + + "github.com/Masterminds/semver/v3" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" + "github.com/grafana/grafana/pkg/setting" +) + +const ( + CreatePluginVersionCfgKey = "create_plugin_version" + CreatePluginVersionScriptSupportEnabled = "4.15.0" +) + +var ( + scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled) +) + +func ProvideService(cfg *setting.Cfg, cdn *pluginscdn.Service) *Service { + return &Service{ + cfg: cfg, + cdn: cdn, + log: log.New("pluginassets"), + } +} + +type Service struct { + cfg *setting.Cfg + cdn *pluginscdn.Service + log log.Logger +} + +// LoadingStrategy calculates the loading strategy for a plugin. +// If a plugin has plugin setting `create_plugin_version` >= 4.15.0, set loadingStrategy to "script". +// If a plugin is not loaded via the CDN and is not Angular, set loadingStrategy to "script". +// Otherwise, set loadingStrategy to "fetch". +func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugins.LoadingStrategy { + if pCfg, ok := s.cfg.PluginSettings[p.ID]; ok { + if s.compatibleCreatePluginVersion(pCfg) { + return plugins.LoadingStrategyScript + } + } + + // If the plugin has a parent, check the parent's create_plugin_version setting + if p.Parent != nil { + if pCfg, ok := s.cfg.PluginSettings[p.Parent.ID]; ok { + if s.compatibleCreatePluginVersion(pCfg) { + return plugins.LoadingStrategyScript + } + } + } + + if !s.cndEnabled(p) && !p.Angular.Detected { + return plugins.LoadingStrategyScript + } + + return plugins.LoadingStrategyFetch +} + +func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { + if cpv, ok := ps[CreatePluginVersionCfgKey]; ok { + createPluginVer, err := semver.NewVersion(cpv) + if err != nil { + s.log.Warn("Failed to parse create plugin version setting as semver", "version", cpv, "error", err) + } else { + if !createPluginVer.LessThan(scriptLoadingMinSupportedVersion) { + return true + } + } + } + return false +} + +func (s *Service) cndEnabled(p pluginstore.Plugin) bool { + return s.cdn.PluginSupported(p.ID) || p.Class == plugins.ClassCDN +} diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go new file mode 100644 index 00000000000..3ca9c4bbccb --- /dev/null +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -0,0 +1,168 @@ +package pluginassets + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/pluginscdn" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" + "github.com/grafana/grafana/pkg/setting" +) + +func TestService_Calculate(t *testing.T) { + const pluginID = "grafana-test-datasource" + + const ( + incompatVersion = "4.14.0" + compatVersion = CreatePluginVersionScriptSupportEnabled + futureVersion = "5.0.0" + ) + + tcs := []struct { + name string + pluginSettings setting.PluginSettings + plugin pluginstore.Plugin + expected plugins.LoadingStrategy + }{ + { + name: "Expected LoadingStrategyScript when create-plugin version is compatible and plugin is not angular", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + CreatePluginVersionCfgKey: compatVersion, + }), + plugin: newPlugin(pluginID, false), + expected: plugins.LoadingStrategyScript, + }, + { + name: "Expected LoadingStrategyScript when parent create-plugin version is compatible and plugin is not angular", + pluginSettings: newPluginSettings("parent-datasource", map[string]string{ + CreatePluginVersionCfgKey: compatVersion, + }), + plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} + return p + }), + expected: plugins.LoadingStrategyScript, + }, + { + name: "Expected LoadingStrategyScript when create-plugin version is future compatible and plugin is not angular", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + CreatePluginVersionCfgKey: futureVersion, + }), + plugin: newPlugin(pluginID, false), + expected: plugins.LoadingStrategyScript, + }, + { + name: "Expected LoadingStrategyScript when create-plugin version is not provided, plugin is not angular and is not configured as CDN enabled", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + // NOTE: cdn key is not set + }), + plugin: newPlugin(pluginID, false), + expected: plugins.LoadingStrategyScript, + }, + { + name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have the CDN class", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + CreatePluginVersionCfgKey: incompatVersion, + // NOTE: cdn key is not set + }), + plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + p.Class = plugins.ClassExternal + return p + }), + expected: plugins.LoadingStrategyScript, + }, + { + name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, is configured as CDN enabled and does not have the CDN class", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + "cdn": "true", + CreatePluginVersionCfgKey: incompatVersion, + }), + plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + p.Class = plugins.ClassExternal + return p + }), + expected: plugins.LoadingStrategyFetch, + }, + { + name: "Expected LoadingStrategyFetch when create-plugin version is not compatible and plugin is angular", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + CreatePluginVersionCfgKey: incompatVersion, + }), + plugin: newPlugin(pluginID, true), + expected: plugins.LoadingStrategyFetch, + }, + { + name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and plugin is configured as CDN enabled", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + "cdn": "true", + CreatePluginVersionCfgKey: incompatVersion, + }), + plugin: newPlugin(pluginID, false), + expected: plugins.LoadingStrategyFetch, + }, + { + name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has the CDN class", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + CreatePluginVersionCfgKey: incompatVersion, + }), + plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin { + p.Class = plugins.ClassCDN + return p + }), + expected: plugins.LoadingStrategyFetch, + }, + { + name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have the CDN class", + pluginSettings: newPluginSettings(pluginID, map[string]string{ + CreatePluginVersionCfgKey: "invalidSemver", + }), + plugin: newPlugin(pluginID, false), + expected: plugins.LoadingStrategyScript, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + s := &Service{ + cfg: newCfg(tc.pluginSettings), + cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{ + PluginsCDNURLTemplate: "http://cdn.example.com", // required for cdn.PluginSupported check + PluginSettings: tc.pluginSettings, + }), + log: log.NewNopLogger(), + } + + got := s.LoadingStrategy(context.Background(), tc.plugin) + assert.Equal(t, tc.expected, got, "unexpected loading strategy") + }) + } +} + +func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin { + p := pluginstore.Plugin{ + JSONData: plugins.JSONData{ + ID: pluginID, + }, + Angular: plugins.AngularMeta{Detected: angular}, + } + for _, cb := range cbs { + p = cb(p) + } + return p +} + +func newCfg(ps setting.PluginSettings) *setting.Cfg { + return &setting.Cfg{ + PluginSettings: ps, + } +} + +func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSettings { + return setting.PluginSettings{ + pluginID: kv, + } +} diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index cc1ec4c80cf..a699982efd8 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -43,6 +43,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/loader" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" @@ -126,6 +127,7 @@ var WireSet = wire.NewSet( plugincontext.ProvideBaseService, wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)), plugininstaller.ProvideService, + pluginassets.ProvideService, ) // WireExtensionSet provides a wire.ProviderSet of plugin providers that can be diff --git a/pkg/services/pluginsintegration/pluginstore/plugins.go b/pkg/services/pluginsintegration/pluginstore/plugins.go index 194ba92ade4..f041c77c5e5 100644 --- a/pkg/services/pluginsintegration/pluginstore/plugins.go +++ b/pkg/services/pluginsintegration/pluginstore/plugins.go @@ -16,6 +16,7 @@ type Plugin struct { Class plugins.Class // App fields + Parent *ParentPlugin IncludedInAppID string DefaultNavURL string Pinned bool @@ -59,7 +60,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { supportsStreaming = true } - return Plugin{ + dto := Plugin{ fs: p.FS, supportsStreaming: supportsStreaming, Class: p.Class, @@ -76,4 +77,14 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { ExternalService: p.ExternalService, Angular: p.Angular, } + + if p.Parent != nil { + dto.Parent = &ParentPlugin{ID: p.Parent.ID} + } + + return dto +} + +type ParentPlugin struct { + ID string } diff --git a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts index 93f28c903b2..60f641dc089 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/plugins.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from 'msw'; -import { PluginMeta } from '@grafana/data'; +import { PluginLoadingStrategy, PluginMeta } from '@grafana/data'; import { config } from '@grafana/runtime'; import { plugins } from 'app/features/alerting/unified/testSetup/plugins'; @@ -18,6 +18,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => { preload: true, version: info.version, angular: angular ?? { detected: false, hideDeprecation: false }, + loadingStrategy: PluginLoadingStrategy.script, }; }); diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index e6ce13414cc..59d77e3b3d3 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -1,3 +1,4 @@ +import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; import { RuleGroupIdentifier } from 'app/types/unified-alerting'; @@ -47,6 +48,7 @@ describe('getRuleOrigin', () => { path: '', preload: true, angular: { detected: false, hideDeprecation: false }, + loadingStrategy: PluginLoadingStrategy.script, }, }; const rule = mockCombinedRule({ diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 62dbcb8c4be..81819a8745f 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -1,6 +1,6 @@ import { ComponentType } from 'react'; -import { PanelPlugin, PanelPluginMeta, PanelProps } from '@grafana/data'; +import { PanelPlugin, PanelPluginMeta, PanelProps, PluginLoadingStrategy } from '@grafana/data'; import config from 'app/core/config'; import { getPanelPluginLoadError } from '../panel/components/PanelPluginError'; @@ -56,10 +56,12 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined { } function getPanelPlugin(meta: PanelPluginMeta): Promise { + const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; return importPluginModule({ path: meta.module, version: meta.info?.version, isAngular: meta.angular?.detected, + loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, }) .then((pluginExports) => { diff --git a/public/app/features/plugins/loader/cache.test.ts b/public/app/features/plugins/loader/cache.test.ts index 531cadbe583..39fa44576be 100644 --- a/public/app/features/plugins/loader/cache.test.ts +++ b/public/app/features/plugins/loader/cache.test.ts @@ -1,4 +1,12 @@ -import { registerPluginInCache, invalidatePluginInCache, resolveWithCache, getPluginFromCache } from './cache'; +import { PluginLoadingStrategy } from '@grafana/data'; + +import { + registerPluginInCache, + invalidatePluginInCache, + resolveWithCache, + getPluginFromCache, + extractCacheKeyFromPath, +} from './cache'; jest.mock('./constants', () => ({ CACHE_INITIALISED_AT: 123456, @@ -7,28 +15,28 @@ jest.mock('./constants', () => ({ describe('Cache Functions', () => { describe('registerPluginInCache', () => { it('should register a plugin in the cache', () => { - const plugin = { pluginId: 'plugin1', version: '1.0.0', isAngular: false }; - registerPluginInCache(plugin); + const plugin = { version: '1.0.0', loadingStrategy: PluginLoadingStrategy.script }; + registerPluginInCache({ path: 'public/plugins/plugin1/module.js', ...plugin }); expect(getPluginFromCache('plugin1')).toEqual(plugin); }); it('should not register a plugin if it already exists in the cache', () => { - const pluginId = 'plugin2'; - const plugin = { pluginId, version: '2.0.0' }; + const path = 'public/plugins/plugin2/module.js'; + const plugin = { path, version: '2.0.0', loadingStrategy: PluginLoadingStrategy.script }; registerPluginInCache(plugin); - const plugin2 = { pluginId, version: '2.5.0' }; + const plugin2 = { path, version: '2.5.0', loadingStrategy: PluginLoadingStrategy.script }; registerPluginInCache(plugin2); - expect(getPluginFromCache(pluginId)?.version).toBe('2.0.0'); + expect(getPluginFromCache(path)?.version).toBe('2.0.0'); }); }); describe('invalidatePluginInCache', () => { it('should invalidate a plugin in the cache', () => { - const pluginId = 'plugin3'; - const plugin = { pluginId, version: '3.0.0' }; + const path = 'public/plugins/plugin2/module.js'; + const plugin = { path, version: '3.0.0', loadingStrategy: PluginLoadingStrategy.script }; registerPluginInCache(plugin); - invalidatePluginInCache(pluginId); - expect(getPluginFromCache(pluginId)).toBeUndefined(); + invalidatePluginInCache('plugin2'); + expect(getPluginFromCache('plugin2')).toBeUndefined(); }); it('should not throw an error if the plugin does not exist in the cache', () => { @@ -43,17 +51,43 @@ describe('Cache Functions', () => { }); it('should resolve URL with plugin version as cache bust parameter if available', () => { - const plugin = { pluginId: 'plugin5', version: '5.0.0' }; - registerPluginInCache(plugin); const url = 'http://localhost:3000/public/plugins/plugin5/module.js'; + const plugin = { path: url, version: '5.0.0', loadingStrategy: PluginLoadingStrategy.script }; + registerPluginInCache(plugin); expect(resolveWithCache(url)).toContain('_cache=5.0.0'); }); }); + describe('extractCacheKeyFromPath', () => { + it('should extract plugin ID from a path', () => { + expect(extractCacheKeyFromPath('public/plugins/plugin6/module.js')).toBe('plugin6'); + }); + + it('should extract plugin ID from a path', () => { + expect(extractCacheKeyFromPath('public/plugins/plugin6/datasource/module.js')).toBe('plugin6'); + }); + + it('should extract plugin ID from a url', () => { + expect(extractCacheKeyFromPath('https://my-url.com/plugin6/1.0.0/public/plugins/plugin6/module.js')).toBe( + 'plugin6' + ); + }); + + it('should extract plugin ID from a nested plugin url', () => { + expect( + extractCacheKeyFromPath('https://my-url.com/plugin6/1.0.0/public/plugins/plugin6/datasource/module.js') + ).toBe('plugin6'); + }); + + it('should return null if the path does not match the pattern', () => { + expect(extractCacheKeyFromPath('public/plugins/plugin7')).toBeNull(); + }); + }); + describe('getPluginFromCache', () => { it('should return plugin from cache if exists', () => { - const plugin = { pluginId: 'plugin6', version: '6.0.0' }; - registerPluginInCache(plugin); + const plugin = { version: '6.0.0', loadingStrategy: PluginLoadingStrategy.script }; + registerPluginInCache({ path: 'public/plugins/plugin6/module.js', ...plugin }); expect(getPluginFromCache('plugin6')).toEqual(plugin); }); diff --git a/public/app/features/plugins/loader/cache.ts b/public/app/features/plugins/loader/cache.ts index 4b3fa40d031..00e77489efd 100644 --- a/public/app/features/plugins/loader/cache.ts +++ b/public/app/features/plugins/loader/cache.ts @@ -1,22 +1,26 @@ +import { PluginLoadingStrategy } from '@grafana/data'; + import { clearPluginSettingsCache } from '../pluginSettings'; import { CACHE_INITIALISED_AT } from './constants'; -const cache: Record = {}; +const cache: Record = {}; type CacheablePlugin = { - pluginId: string; + path: string; version: string; - isAngular?: boolean; + loadingStrategy: PluginLoadingStrategy; }; -export function registerPluginInCache({ pluginId, version, isAngular }: CacheablePlugin): void { - const key = pluginId; +type CachedPlugin = Omit; + +export function registerPluginInCache({ path, version, loadingStrategy }: CacheablePlugin): void { + const key = extractCacheKeyFromPath(path); + if (key && !cache[key]) { cache[key] = { version: encodeURI(version), - isAngular, - pluginId, + loadingStrategy, }; } } @@ -39,7 +43,7 @@ export function resolveWithCache(url: string, defaultBust = CACHE_INITIALISED_AT return `${url}?_cache=${bust}`; } -export function getPluginFromCache(path: string): CacheablePlugin | undefined { +export function getPluginFromCache(path: string): CachedPlugin | undefined { const key = getCacheKey(path); if (!key) { return; @@ -47,6 +51,12 @@ export function getPluginFromCache(path: string): CacheablePlugin | undefined { return cache[key]; } +export function extractCacheKeyFromPath(path: string) { + const regex = /\/?public\/plugins\/([^\/]+)\//; + const match = path.match(regex); + return match ? match[1] : null; +} + function getCacheKey(address: string): string | undefined { const key = Object.keys(cache).find((key) => address.includes(key)); if (!key) { diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index 024d743a652..378ad0ba257 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -5,7 +5,7 @@ import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { PluginExtensionRegistries } from './extensions/registry/types'; -import * as pluginLoader from './plugin_loader'; +import { importPluginModule } from './plugin_loader'; export type PluginPreloadResult = { pluginId: string; @@ -48,14 +48,15 @@ export async function preloadPlugins( } async function preload(config: AppPluginConfig): Promise { - const { path, version, id: pluginId } = config; + const { path, version, id: pluginId, loadingStrategy } = config; try { startMeasure(`frontend_plugin_preload_${pluginId}`); - const { plugin } = await pluginLoader.importPluginModule({ + const { plugin } = await importPluginModule({ path, version, isAngular: config.angular.detected, pluginId, + loadingStrategy, }); const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 588e938484b..e9f7866761b 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -4,6 +4,7 @@ import { DataSourceJsonData, DataSourcePlugin, DataSourcePluginMeta, + PluginLoadingStrategy, PluginMeta, } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; @@ -18,7 +19,7 @@ import { SystemJS } from './loader/systemjs'; import { sharedDependenciesMap } from './loader/sharedDependencies'; import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload } from './loader/systemjsHooks'; import { SystemJSWithLoaderHooks } from './loader/types'; -import { buildImportMap, isHostedOnCDN, resolveModulePath } from './loader/utils'; +import { buildImportMap, resolveModulePath } from './loader/utils'; import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader'; import { isFrontendSandboxSupported } from './sandbox/utils'; @@ -29,13 +30,17 @@ SystemJS.addImportMap({ imports }); const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype; // This instructs SystemJS to load plugin assets using fetch and eval if it returns a truthy value, otherwise -// it will load the plugin using a script tag. We only want to fetch and eval files that are -// hosted on a CDN, are related to Angular plugins or are not js files. +// it will load the plugin using a script tag. The logic that sets loadingStrategy comes from the backend. +// See: pkg/services/pluginsintegration/pluginassets/pluginassets.go systemJSPrototype.shouldFetch = function (url) { const pluginInfo = getPluginFromCache(url); const jsTypeRegEx = /^[^#?]+\.(js)([?#].*)?$/; - return isHostedOnCDN(url) || Boolean(pluginInfo?.isAngular) || !jsTypeRegEx.test(url); + if (!jsTypeRegEx.test(url)) { + return true; + } + + return Boolean(pluginInfo?.loadingStrategy !== PluginLoadingStrategy.script); }; const originalImport = systemJSPrototype.import; @@ -64,17 +69,19 @@ systemJSPrototype.onload = decorateSystemJsOnload; export async function importPluginModule({ path, + pluginId, + loadingStrategy, version, isAngular, - pluginId, }: { path: string; pluginId: string; + loadingStrategy: PluginLoadingStrategy; version?: string; isAngular?: boolean; }): Promise { if (version) { - registerPluginInCache({ pluginId, version, isAngular }); + registerPluginInCache({ path, version, loadingStrategy }); } const builtIn = builtInPlugins[path]; @@ -99,10 +106,12 @@ export async function importPluginModule({ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { const isAngular = meta.angular?.detected ?? meta.angularDetected; + const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; return importPluginModule({ path: meta.module, version: meta.info?.version, isAngular, + loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, }).then((pluginExports) => { if (pluginExports.plugin) { @@ -128,10 +137,12 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { const isAngular = meta.angular?.detected ?? meta.angularDetected; + const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; return importPluginModule({ path: meta.module, version: meta.info?.version, isAngular, + loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, }).then((pluginExports) => { const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin();