mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugins: Allow loading panel plugins from a CDN (#59096)
* POC: Plugins CDN reverse proxy * CDN proxy POC: changed env var names * Add authorization: false for /public path in frontend plugin loader * Moved CDN settings to Cfg, add some comments * Fix error 500 in asset fetch if plugin is not using CDN * Fix EnterpriseLicensePath declared twice * Fix linter complaining about whitespaces * Plugins CDN: Skip signature verification for CDN plugins * Plugins CDN: Skip manifest and signature check for cdn plugins * Plugins: use IsValid() and IsInternal() rather than equality checks * Plugins CDN: remove comment * Plugins CDN: Fix seeker can't seek when serving plugins from local fs * Plugins CDN: add back error codes in getLocalPluginAssets * Plugins CDN: call asset.Close() rather than asset.readSeekCloser.Close() * Plugins CDN: Fix panic in JsonApiErr when errorMessageCoder wraps a nil error * Plugins CDN: Add error handling to proxyCDNPluginAsset * Plugins CDN: replace errorMessageCoder with errutil * Plugins CDN POC: expose cdn plugin paths to frontend for system.js * Plugins CDN: Fix cdn plugins showing as unsigned in frontend * WIP: Add support for formatted URL * Fix missing cdnPluginsBaseURLs in GrafanaConfig * Plugins CDN: Remove reverse proxy mode and reverse proxy references * Plugins CDN: Simplify asset serving logic * Plugins CDN: sanitize redirect path * Plugins CDN: Removed unused pluginAsset type * Plugins CDN: Removed system.js changes * Plugins CDN: Return different system.js baseURL and module for cdn plugins * Plugins CDN: Ensure CDN is disabled for non-external plugins * lint * Plugins CDN: serve images and screenshots from CDN, refactoring * Lint * Plugins CDN: Fix URLs for system.js (baseUrl and module) * Plugins CDN: Add more tests for RelativeURLForSystemJS * Plugins CDN: Iterate only on apps when preloading * Plugins CDN: Refactoring * Plugins CDN: Add comments to url_constructor.go * Plugins CDN: Update defaultHGPluginsCDNBaseURL * Plugins CDN: undo extract meta from system js config * refactor(plugins): migrate systemjs css plugin to typescript * feat(plugins): introduce systemjs cdn loader plugin * feat(plugins): add systemjs load type * Plugins CDN: Removed RelativeURLForSystemJS * Plugins CDN: Log backend redirect hits along with plugin info * Plugins CDN: Add pluginsCDNBasePath to getFrontendSettingsMap * feat(plugins): introduce cdn loading for angular plugins * refactor(plugins): move systemjs cache buster into systemjsplugins directory * Plugins CDN: Rename pluginsCDNBasePath to pluginsCDNBaseURL * refactor(plugins): introduce pluginsCDNBaseURL to the frontend * Plugins CDN: Renamed "cdn base path" to "cdn url template" in backend * Plugins CDN: lint * merge with main * Instrumentation: Add prometheus counter for backend hits, log from Info to Warn * Config: Changed key from plugins_cdn.url to plugins.plugins_cdn_base_url * CDN: Add backend tests * Lint: goimports * Default CDN URL to empty string, * Do not use CDN in setImages and module if the url template is empty * CDN: Backend: Add test for frontend settings * CDN: Do not log missing module.js warn if plugin is being loaded from CDN * CDN: Add backend test for CDN plugin loader * Removed 'cdn' signature level, switch to 'valid' * Fix pfs.TestParseTreeTestdata for cdn plugin testdata dir * Fix TestLoader_Load * Fix gocyclo complexity of loadPlugins * Plugins CDN: Moved prometheus metric to api package, removed asset_path label * Fix missing in config * Changes after review * Add pluginscdn.Service * Fix tests * Refactoring * Moved all remaining CDN checks inside pluginscdn.Service * CDN url constructor: Renamed stringURLFor to stringPath * CDN: Moved asset URL functionality to assetpath service * CDN: Renamed HasCDN() to IsEnabled() * CDN: Replace assert with require * CDN: Changes after review * Assetpath: Handle url.Parse error * Fix plugin_resource_test * CDN: Change fallback redirect from 302 to 307 * goimports * Fix tests * Switch to contextmodel.ReqContext in plugins.go Co-authored-by: Will Browne <will.browne@grafana.com> Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
@@ -111,6 +111,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
pluginAdminEnabled = true;
|
pluginAdminEnabled = true;
|
||||||
pluginAdminExternalManageEnabled = false;
|
pluginAdminExternalManageEnabled = false;
|
||||||
pluginCatalogHiddenPlugins: string[] = [];
|
pluginCatalogHiddenPlugins: string[] = [];
|
||||||
|
pluginsCDNBaseURL = '';
|
||||||
expressionsEnabled = false;
|
expressionsEnabled = false;
|
||||||
customTheme?: undefined;
|
customTheme?: undefined;
|
||||||
awsAllowedAuthProviders: string[] = [];
|
awsAllowedAuthProviders: string[] = [];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -212,6 +213,14 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *contextmodel.ReqContext) (map[st
|
|||||||
"snapshotEnabled": hs.Cfg.SnapshotEnabled,
|
"snapshotEnabled": hs.Cfg.SnapshotEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() {
|
||||||
|
cdnBaseURL, err := hs.pluginsCDNService.BaseURL()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plugins cdn base url: %w", err)
|
||||||
|
}
|
||||||
|
jsonObj["pluginsCDNBaseURL"] = cdnBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
if hs.ThumbService != nil {
|
if hs.ThumbService != nil {
|
||||||
jsonObj["dashboardPreviews"] = hs.ThumbService.GetDashboardPreviewsSetupSettings(c)
|
jsonObj["dashboardPreviews"] = hs.ThumbService.GetDashboardPreviewsSetupSettings(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/login/social"
|
"github.com/grafana/grafana/pkg/login/social"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -58,7 +60,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
|
|||||||
grafanaUpdateChecker: &updatechecker.GrafanaService{},
|
grafanaUpdateChecker: &updatechecker.GrafanaService{},
|
||||||
AccessControl: accesscontrolmock.New().WithDisabled(),
|
AccessControl: accesscontrolmock.New().WithDisabled(),
|
||||||
PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService),
|
PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService),
|
||||||
SocialService: social.ProvideService(cfg, features),
|
pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{
|
||||||
|
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
|
||||||
|
PluginSettings: cfg.PluginSettings,
|
||||||
|
}),
|
||||||
|
SocialService: social.ProvideService(cfg, features),
|
||||||
}
|
}
|
||||||
|
|
||||||
m := web.New()
|
m := web.New()
|
||||||
@@ -138,3 +144,53 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) {
|
||||||
|
type settings struct {
|
||||||
|
PluginsCDNBaseURL string `json:"pluginsCDNBaseURL"`
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
desc string
|
||||||
|
mutateCfg func(*setting.Cfg)
|
||||||
|
expected settings
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
desc: "With CDN",
|
||||||
|
mutateCfg: func(cfg *setting.Cfg) {
|
||||||
|
cfg.PluginsCDNURLTemplate = "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}"
|
||||||
|
},
|
||||||
|
expected: settings{PluginsCDNBaseURL: "https://cdn.example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "Without CDN",
|
||||||
|
mutateCfg: func(cfg *setting.Cfg) {
|
||||||
|
cfg.PluginsCDNURLTemplate = ""
|
||||||
|
},
|
||||||
|
expected: settings{PluginsCDNBaseURL: ""},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
desc: "CDN is disabled by default",
|
||||||
|
expected: settings{PluginsCDNBaseURL: ""},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.desc, func(t *testing.T) {
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
if test.mutateCfg != nil {
|
||||||
|
test.mutateCfg(cfg)
|
||||||
|
}
|
||||||
|
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures())
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
m.ServeHTTP(recorder, req)
|
||||||
|
var got settings
|
||||||
|
err := json.Unmarshal(recorder.Body.Bytes(), &got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
require.EqualValues(t, test.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/middleware/csrf"
|
"github.com/grafana/grafana/pkg/middleware/csrf"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
"github.com/grafana/grafana/pkg/registry/corekind"
|
"github.com/grafana/grafana/pkg/registry/corekind"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/alerting"
|
"github.com/grafana/grafana/pkg/services/alerting"
|
||||||
@@ -201,6 +202,7 @@ type HTTPServer struct {
|
|||||||
playlistService playlist.Service
|
playlistService playlist.Service
|
||||||
apiKeyService apikey.Service
|
apiKeyService apikey.Service
|
||||||
kvStore kvstore.KVStore
|
kvStore kvstore.KVStore
|
||||||
|
pluginsCDNService *pluginscdn.Service
|
||||||
|
|
||||||
userService user.Service
|
userService user.Service
|
||||||
tempUserService tempUser.Service
|
tempUserService tempUser.Service
|
||||||
@@ -258,6 +260,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService,
|
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService,
|
||||||
queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService,
|
queryLibraryHTTPService querylibrary.HTTPService, queryLibraryService querylibrary.Service, oauthTokenService oauthtoken.OAuthTokenService,
|
||||||
statsService stats.Service, authnService authn.Service,
|
statsService stats.Service, authnService authn.Service,
|
||||||
|
pluginsCDNService *pluginscdn.Service,
|
||||||
k8saccess k8saccess.K8SAccess, // required so that the router is registered
|
k8saccess k8saccess.K8SAccess, // required so that the router is registered
|
||||||
starApi *starApi.API,
|
starApi *starApi.API,
|
||||||
) (*HTTPServer, error) {
|
) (*HTTPServer, error) {
|
||||||
@@ -366,6 +369,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
oauthTokenService: oauthTokenService,
|
oauthTokenService: oauthTokenService,
|
||||||
statsService: statsService,
|
statsService: statsService,
|
||||||
authnService: authnService,
|
authnService: authnService,
|
||||||
|
pluginsCDNService: pluginsCDNService,
|
||||||
starApi: starApi,
|
starApi: starApi,
|
||||||
}
|
}
|
||||||
if hs.Listener != nil {
|
if hs.Listener != nil {
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import (
|
|||||||
pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client"
|
pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/fakes"
|
"github.com/grafana/grafana/pkg/plugins/manager/fakes"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/store"
|
"github.com/grafana/grafana/pkg/plugins/manager/store"
|
||||||
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
datasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
datasources "github.com/grafana/grafana/pkg/services/datasources/fakes"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -56,8 +58,9 @@ func TestCallResource(t *testing.T) {
|
|||||||
nil, nil, nil, nil, testdatasource.ProvideService(cfg, featuremgmt.WithFeatures()), nil, nil, nil, nil, nil, nil)
|
nil, nil, nil, nil, testdatasource.ProvideService(cfg, featuremgmt.WithFeatures()), nil, nil, nil, nil, nil, nil)
|
||||||
pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg)
|
pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg)
|
||||||
reg := registry.ProvideService()
|
reg := registry.ProvideService()
|
||||||
|
cdn := pluginscdn.ProvideService(pCfg)
|
||||||
l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg),
|
l := loader.ProvideService(pCfg, fakes.NewFakeLicensingService(), signature.NewUnsignedAuthorizer(pCfg),
|
||||||
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry())
|
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry(), cdn, assetpath.ProvideService(cdn))
|
||||||
ps, err := store.ProvideService(cfg, pCfg, reg, l)
|
ps, err := store.ProvideService(cfg, pCfg, reg, l)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
@@ -33,6 +35,14 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/web"
|
"github.com/grafana/grafana/pkg/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pluginsCDNFallbackRedirectRequests is a metric counter keeping track of how many
|
||||||
|
// requests are received on the plugins CDN backend redirect fallback handler.
|
||||||
|
var pluginsCDNFallbackRedirectRequests = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Namespace: "grafana",
|
||||||
|
Name: "plugins_cdn_fallback_redirect_requests_total",
|
||||||
|
Help: "Number of requests to the plugins CDN backend redirect fallback handler.",
|
||||||
|
}, []string{"plugin_id", "plugin_version"})
|
||||||
|
|
||||||
func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Response {
|
||||||
typeFilter := c.Query("type")
|
typeFilter := c.Query("type")
|
||||||
enabledFilter := c.Query("enabled")
|
enabledFilter := c.Query("enabled")
|
||||||
@@ -301,6 +311,13 @@ func (hs *HTTPServer) CollectPluginMetrics(c *contextmodel.ReqContext) response.
|
|||||||
|
|
||||||
// getPluginAssets returns public plugin assets (images, JS, etc.)
|
// getPluginAssets returns public plugin assets (images, JS, etc.)
|
||||||
//
|
//
|
||||||
|
// If the plugin has cdn = false in its config (default), it will always attempt to return the asset
|
||||||
|
// from the local filesystem.
|
||||||
|
//
|
||||||
|
// If the plugin has cdn = true and hs.Cfg.PluginsCDNURLTemplate is empty, it will get the file
|
||||||
|
// from the local filesystem. If hs.Cfg.PluginsCDNURLTemplate is not empty,
|
||||||
|
// this handler returns a redirect to the plugin asset file on the specified CDN.
|
||||||
|
//
|
||||||
// /public/plugins/:pluginId/*
|
// /public/plugins/:pluginId/*
|
||||||
func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
|
func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
|
||||||
pluginID := web.Params(c.Req)[":pluginId"]
|
pluginID := web.Params(c.Req)[":pluginId"]
|
||||||
@@ -318,7 +335,19 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := plugin.File(requestedFile)
|
if hs.pluginsCDNService.PluginSupported(pluginID) {
|
||||||
|
// Send a redirect to the client
|
||||||
|
hs.redirectCDNPluginAsset(c, plugin, requestedFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the actual file to the client from local filesystem
|
||||||
|
hs.serveLocalPluginAsset(c, plugin, requestedFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveLocalPluginAsset returns the content of a plugin asset file from the local filesystem to the http client.
|
||||||
|
func (hs *HTTPServer) serveLocalPluginAsset(c *contextmodel.ReqContext, plugin plugins.PluginDTO, assetPath string) {
|
||||||
|
f, err := plugin.File(assetPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, plugins.ErrFileNotExist) {
|
if errors.Is(err, plugins.ErrFileNotExist) {
|
||||||
c.JsonApiErr(404, "Plugin file not found", nil)
|
c.JsonApiErr(404, "Plugin file not found", nil)
|
||||||
@@ -346,15 +375,37 @@ func (hs *HTTPServer) getPluginAssets(c *contextmodel.ReqContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rs, ok := f.(io.ReadSeeker); ok {
|
if rs, ok := f.(io.ReadSeeker); ok {
|
||||||
http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), rs)
|
http.ServeContent(c.Resp, c.Req, assetPath, fi.ModTime(), rs)
|
||||||
} else {
|
return
|
||||||
b, err := io.ReadAll(f)
|
|
||||||
if err != nil {
|
|
||||||
c.JsonApiErr(500, "Plugin file exists but could not read", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.ServeContent(c.Resp, c.Req, requestedFile, fi.ModTime(), bytes.NewReader(b))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
b, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
c.JsonApiErr(500, "Plugin file exists but could not read", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeContent(c.Resp, c.Req, assetPath, fi.ModTime(), bytes.NewReader(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectCDNPluginAsset redirects the http request to specified asset path on the configured plugins CDN.
|
||||||
|
func (hs *HTTPServer) redirectCDNPluginAsset(c *contextmodel.ReqContext, plugin plugins.PluginDTO, assetPath string) {
|
||||||
|
remoteURL, err := hs.pluginsCDNService.AssetURL(plugin.ID, plugin.Info.Version, assetPath)
|
||||||
|
if err != nil {
|
||||||
|
c.JsonApiErr(500, "Failed to get CDN plugin asset remote URL", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hs.log.Warn(
|
||||||
|
"plugin cdn redirect hit",
|
||||||
|
"pluginID", plugin.ID,
|
||||||
|
"pluginVersion", plugin.Info.Version,
|
||||||
|
"assetPath", assetPath,
|
||||||
|
"remoteURL", remoteURL,
|
||||||
|
)
|
||||||
|
pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{
|
||||||
|
"plugin_id": plugin.ID,
|
||||||
|
"plugin_version": plugin.Info.Version,
|
||||||
|
}).Inc()
|
||||||
|
http.Redirect(c.Resp, c.Req, remoteURL, http.StatusTemporaryRedirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckHealth returns the health of a plugin.
|
// CheckHealth returns the health of a plugin.
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||||
@@ -21,6 +23,8 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/log/logtest"
|
"github.com/grafana/grafana/pkg/infra/log/logtest"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
@@ -139,7 +143,7 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) {
|
|||||||
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(1, tc.permissions))
|
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/install", input), userWithPermissions(1, tc.permissions))
|
||||||
res, err := server.SendJSON(req)
|
res, err := server.SendJSON(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tc.expectedCode, res.StatusCode)
|
require.Equal(t, tc.expectedCode, res.StatusCode)
|
||||||
require.NoError(t, res.Body.Close())
|
require.NoError(t, res.Body.Close())
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,12 +152,105 @@ func Test_PluginsInstallAndUninstall_AccessControl(t *testing.T) {
|
|||||||
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(1, tc.permissions))
|
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/plugins/test/uninstall", input), userWithPermissions(1, tc.permissions))
|
||||||
res, err := server.SendJSON(req)
|
res, err := server.SendJSON(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, tc.expectedCode, res.StatusCode)
|
require.Equal(t, tc.expectedCode, res.StatusCode)
|
||||||
require.NoError(t, res.Body.Close())
|
require.NoError(t, res.Body.Close())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_GetPluginAssetCDNRedirect(t *testing.T) {
|
||||||
|
const cdnPluginID = "cdn-plugin"
|
||||||
|
const nonCDNPluginID = "non-cdn-plugin"
|
||||||
|
t.Run("Plugin CDN asset redirect", func(t *testing.T) {
|
||||||
|
cdnPlugin := &plugins.Plugin{
|
||||||
|
JSONData: plugins.JSONData{ID: cdnPluginID, Info: plugins.Info{Version: "1.0.0"}},
|
||||||
|
}
|
||||||
|
nonCdnPlugin := &plugins.Plugin{
|
||||||
|
JSONData: plugins.JSONData{ID: nonCDNPluginID, Info: plugins.Info{Version: "2.0.0"}},
|
||||||
|
}
|
||||||
|
service := &plugins.FakePluginStore{
|
||||||
|
PluginList: []plugins.PluginDTO{
|
||||||
|
cdnPlugin.ToDTO(),
|
||||||
|
nonCdnPlugin.ToDTO(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
cfg.PluginsCDNURLTemplate = "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}"
|
||||||
|
cfg.PluginSettings = map[string]map[string]string{
|
||||||
|
cdnPluginID: {"cdn": "true"},
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdnFolderBaseURL = "https://cdn.example.com/cdn-plugin/1.0.0/public/plugins/cdn-plugin"
|
||||||
|
|
||||||
|
type tc struct {
|
||||||
|
assetURL string
|
||||||
|
expRelativeURL string
|
||||||
|
}
|
||||||
|
for _, cas := range []tc{
|
||||||
|
{"module.js", "module.js"},
|
||||||
|
{"other/folder/file.js", "other/folder/file.js"},
|
||||||
|
{"double////slashes/file.js", "double/slashes/file.js"},
|
||||||
|
} {
|
||||||
|
pluginAssetScenario(
|
||||||
|
t,
|
||||||
|
"When calling GET for a CDN plugin on",
|
||||||
|
fmt.Sprintf("/public/plugins/%s/%s", cdnPluginID, cas.assetURL),
|
||||||
|
"/public/plugins/:pluginId/*",
|
||||||
|
cfg, service, func(sc *scenarioContext) {
|
||||||
|
// Get the prometheus metric (to test that the handler is instrumented correctly)
|
||||||
|
counter := pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{
|
||||||
|
"plugin_id": cdnPluginID,
|
||||||
|
"plugin_version": "1.0.0",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Encode the prometheus metric and get its value
|
||||||
|
var m dto.Metric
|
||||||
|
require.NoError(t, counter.Write(&m))
|
||||||
|
before := m.Counter.GetValue()
|
||||||
|
|
||||||
|
// Call handler
|
||||||
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
|
// Check redirect code + location
|
||||||
|
require.Equal(t, http.StatusTemporaryRedirect, sc.resp.Code, "wrong status code")
|
||||||
|
require.Equal(t, cdnFolderBaseURL+"/"+cas.expRelativeURL, sc.resp.Header().Get("Location"), "wrong location header")
|
||||||
|
|
||||||
|
// Check metric
|
||||||
|
require.NoError(t, counter.Write(&m))
|
||||||
|
require.Equal(t, before+1, m.Counter.GetValue(), "prometheus metric not incremented")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pluginAssetScenario(
|
||||||
|
t,
|
||||||
|
"When calling GET for a non-CDN plugin on",
|
||||||
|
fmt.Sprintf("/public/plugins/%s/%s", nonCDNPluginID, "module.js"),
|
||||||
|
"/public/plugins/:pluginId/*",
|
||||||
|
cfg, service, func(sc *scenarioContext) {
|
||||||
|
// Here the metric should not increment
|
||||||
|
var m dto.Metric
|
||||||
|
counter := pluginsCDNFallbackRedirectRequests.With(prometheus.Labels{
|
||||||
|
"plugin_id": nonCDNPluginID,
|
||||||
|
"plugin_version": "2.0.0",
|
||||||
|
})
|
||||||
|
require.NoError(t, counter.Write(&m))
|
||||||
|
require.Zero(t, m.Counter.GetValue())
|
||||||
|
|
||||||
|
// Call handler
|
||||||
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
|
// 404 implies access to fs
|
||||||
|
require.Equal(t, http.StatusNotFound, sc.resp.Code)
|
||||||
|
require.Empty(t, sc.resp.Header().Get("Location"))
|
||||||
|
|
||||||
|
// Ensure the metric did not change
|
||||||
|
require.NoError(t, counter.Write(&m))
|
||||||
|
require.Zero(t, m.Counter.GetValue())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func Test_GetPluginAssets(t *testing.T) {
|
func Test_GetPluginAssets(t *testing.T) {
|
||||||
pluginID := "test-plugin"
|
pluginID := "test-plugin"
|
||||||
pluginDir := "."
|
pluginDir := "."
|
||||||
@@ -185,8 +282,8 @@ func Test_GetPluginAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
||||||
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
|
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
|
||||||
func(sc *scenarioContext) {
|
setting.NewCfg(), service, func(sc *scenarioContext) {
|
||||||
callGetPluginAsset(sc)
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
require.Equal(t, 200, sc.resp.Code)
|
require.Equal(t, 200, sc.resp.Code)
|
||||||
@@ -201,8 +298,8 @@ func Test_GetPluginAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name())
|
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, tmpFileInParentDir.Name())
|
||||||
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
|
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
|
||||||
func(sc *scenarioContext) {
|
setting.NewCfg(), service, func(sc *scenarioContext) {
|
||||||
callGetPluginAsset(sc)
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
require.Equal(t, 404, sc.resp.Code)
|
require.Equal(t, 404, sc.resp.Code)
|
||||||
@@ -217,8 +314,8 @@ func Test_GetPluginAssets(t *testing.T) {
|
|||||||
|
|
||||||
requestedFile := "nonExistent"
|
requestedFile := "nonExistent"
|
||||||
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
||||||
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
|
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
|
||||||
func(sc *scenarioContext) {
|
setting.NewCfg(), service, func(sc *scenarioContext) {
|
||||||
callGetPluginAsset(sc)
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
var respJson map[string]interface{}
|
var respJson map[string]interface{}
|
||||||
@@ -237,8 +334,8 @@ func Test_GetPluginAssets(t *testing.T) {
|
|||||||
|
|
||||||
requestedFile := "nonExistent"
|
requestedFile := "nonExistent"
|
||||||
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
||||||
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
|
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
|
||||||
func(sc *scenarioContext) {
|
setting.NewCfg(), service, func(sc *scenarioContext) {
|
||||||
callGetPluginAsset(sc)
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
var respJson map[string]interface{}
|
var respJson map[string]interface{}
|
||||||
@@ -262,8 +359,8 @@ func Test_GetPluginAssets(t *testing.T) {
|
|||||||
l := &logtest.Fake{}
|
l := &logtest.Fake{}
|
||||||
|
|
||||||
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
url := fmt.Sprintf("/public/plugins/%s/%s", pluginID, requestedFile)
|
||||||
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*", service,
|
pluginAssetScenario(t, "When calling GET on", url, "/public/plugins/:pluginId/*",
|
||||||
func(sc *scenarioContext) {
|
setting.NewCfg(), service, func(sc *scenarioContext) {
|
||||||
callGetPluginAsset(sc)
|
callGetPluginAsset(sc)
|
||||||
|
|
||||||
require.Equal(t, 200, sc.resp.Code)
|
require.Equal(t, 200, sc.resp.Code)
|
||||||
@@ -383,12 +480,18 @@ func callGetPluginAsset(sc *scenarioContext) {
|
|||||||
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string, pluginStore plugins.Store,
|
func pluginAssetScenario(t *testing.T, desc string, url string, urlPattern string,
|
||||||
fn scenarioFunc) {
|
cfg *setting.Cfg, pluginStore plugins.Store, fn scenarioFunc) {
|
||||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||||
|
cfg.IsFeatureToggleEnabled = func(_ string) bool { return false }
|
||||||
hs := HTTPServer{
|
hs := HTTPServer{
|
||||||
Cfg: setting.NewCfg(),
|
Cfg: cfg,
|
||||||
pluginStore: pluginStore,
|
pluginStore: pluginStore,
|
||||||
|
log: log.NewNopLogger(),
|
||||||
|
pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{
|
||||||
|
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
|
||||||
|
PluginSettings: cfg.PluginSettings,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
sc := setupScenarioContext(t, url)
|
sc := setupScenarioContext(t, url)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type Cfg struct {
|
|||||||
BuildVersion string // TODO Remove
|
BuildVersion string // TODO Remove
|
||||||
|
|
||||||
LogDatasourceRequests bool
|
LogDatasourceRequests bool
|
||||||
|
|
||||||
|
PluginsCDNURLTemplate string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg {
|
func ProvideConfig(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg {
|
||||||
@@ -63,6 +65,7 @@ func NewCfg(settingProvider setting.Provider, grafanaCfg *setting.Cfg) *Cfg {
|
|||||||
AWSAssumeRoleEnabled: aws.KeyValue("assume_role_enabled").MustBool(grafanaCfg.AWSAssumeRoleEnabled),
|
AWSAssumeRoleEnabled: aws.KeyValue("assume_role_enabled").MustBool(grafanaCfg.AWSAssumeRoleEnabled),
|
||||||
Azure: grafanaCfg.Azure,
|
Azure: grafanaCfg.Azure,
|
||||||
LogDatasourceRequests: grafanaCfg.IsFeatureToggleEnabled(featuremgmt.FlagDatasourceLogger),
|
LogDatasourceRequests: grafanaCfg.IsFeatureToggleEnabled(featuremgmt.FlagDatasourceLogger),
|
||||||
|
PluginsCDNURLTemplate: grafanaCfg.PluginsCDNURLTemplate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
pkg/plugins/manager/loader/assetpath/assetpath.go
Normal file
70
pkg/plugins/manager/loader/assetpath/assetpath.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package assetpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service provides methods for constructing asset paths for plugins.
|
||||||
|
// It supports core plugins, external plugins stored on the local filesystem, and external plugins stored
|
||||||
|
// on the plugins CDN, and it will switch to the correct implementation depending on the plugin and the config.
|
||||||
|
type Service struct {
|
||||||
|
cdn *pluginscdn.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProvideService(cdn *pluginscdn.Service) *Service {
|
||||||
|
return &Service{cdn: cdn}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base returns the base path for the specified plugin.
|
||||||
|
func (s *Service) Base(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (string, error) {
|
||||||
|
if class == plugins.Core {
|
||||||
|
return path.Join("public/app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir)), nil
|
||||||
|
}
|
||||||
|
if s.cdn.PluginSupported(pluginJSON.ID) {
|
||||||
|
return s.cdn.SystemJSAssetPath(pluginJSON.ID, pluginJSON.Info.Version, "")
|
||||||
|
}
|
||||||
|
return path.Join("public/plugins", pluginJSON.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module returns the module.js path for the specified plugin.
|
||||||
|
func (s *Service) Module(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (string, error) {
|
||||||
|
if class == plugins.Core {
|
||||||
|
return path.Join("app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir), "module"), nil
|
||||||
|
}
|
||||||
|
if s.cdn.PluginSupported(pluginJSON.ID) {
|
||||||
|
return s.cdn.SystemJSAssetPath(pluginJSON.ID, pluginJSON.Info.Version, "module")
|
||||||
|
}
|
||||||
|
return path.Join("plugins", pluginJSON.ID, "module"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelativeURL returns the relative URL for an arbitrary plugin asset.
|
||||||
|
// If pathStr is an empty string, defaultStr is returned.
|
||||||
|
func (s *Service) RelativeURL(p *plugins.Plugin, pathStr, defaultStr string) (string, error) {
|
||||||
|
if pathStr == "" {
|
||||||
|
return defaultStr, nil
|
||||||
|
}
|
||||||
|
if s.cdn.PluginSupported(p.ID) {
|
||||||
|
// CDN
|
||||||
|
return s.cdn.NewCDNURLConstructor(p.ID, p.Info.Version).StringPath(pathStr)
|
||||||
|
}
|
||||||
|
// Local
|
||||||
|
u, err := url.Parse(pathStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("url parse: %w", err)
|
||||||
|
}
|
||||||
|
if u.IsAbs() {
|
||||||
|
return pathStr, nil
|
||||||
|
}
|
||||||
|
// is set as default or has already been prefixed with base path
|
||||||
|
if pathStr == defaultStr || strings.HasPrefix(pathStr, p.BaseURL) {
|
||||||
|
return pathStr, nil
|
||||||
|
}
|
||||||
|
return path.Join(p.BaseURL, pathStr), nil
|
||||||
|
}
|
||||||
88
pkg/plugins/manager/loader/assetpath/assetpath_test.go
Normal file
88
pkg/plugins/manager/loader/assetpath/assetpath_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package assetpath
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func extPath(pluginID string) string {
|
||||||
|
return "/grafana/data/plugins/" + pluginID
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService(t *testing.T) {
|
||||||
|
svc := ProvideService(pluginscdn.ProvideService(&config.Cfg{
|
||||||
|
PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}",
|
||||||
|
PluginSettings: map[string]map[string]string{
|
||||||
|
"one": {"cdn": "true"},
|
||||||
|
"two": {},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const tableOldPath = "/grafana/public/app/plugins/panel/table-old"
|
||||||
|
jsonData := map[string]plugins.JSONData{
|
||||||
|
"table-old": {ID: "table-old", Info: plugins.Info{Version: "1.0.0"}},
|
||||||
|
|
||||||
|
"one": {ID: "one", Info: plugins.Info{Version: "1.0.0"}},
|
||||||
|
"two": {ID: "two", Info: plugins.Info{Version: "2.0.0"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Base", func(t *testing.T) {
|
||||||
|
base, err := svc.Base(jsonData["one"], plugins.External, extPath("one"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "plugin-cdn/one/1.0.0/public/plugins/one", base)
|
||||||
|
|
||||||
|
base, err = svc.Base(jsonData["two"], plugins.External, extPath("two"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "public/plugins/two", base)
|
||||||
|
|
||||||
|
base, err = svc.Base(jsonData["table-old"], plugins.Core, tableOldPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "public/app/plugins/table-old", base)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Module", func(t *testing.T) {
|
||||||
|
module, err := svc.Module(jsonData["one"], plugins.External, extPath("one"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "plugin-cdn/one/1.0.0/public/plugins/one/module", module)
|
||||||
|
|
||||||
|
module, err = svc.Module(jsonData["two"], plugins.External, extPath("two"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "plugins/two/module", module)
|
||||||
|
|
||||||
|
module, err = svc.Module(jsonData["table-old"], plugins.Core, tableOldPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "app/plugins/table-old/module", module)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("RelativeURL", func(t *testing.T) {
|
||||||
|
pluginsMap := map[string]*plugins.Plugin{
|
||||||
|
"one": {
|
||||||
|
JSONData: plugins.JSONData{ID: "one", Info: plugins.Info{Version: "1.0.0"}},
|
||||||
|
BaseURL: "plugin-cdn/one/1.0.0/public/pluginsMap/one",
|
||||||
|
},
|
||||||
|
"two": {
|
||||||
|
JSONData: plugins.JSONData{ID: "two", Info: plugins.Info{Version: "2.0.0"}},
|
||||||
|
BaseURL: "public/pluginsMap/two",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
u, err := svc.RelativeURL(pluginsMap["one"], "", "default")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "default", u)
|
||||||
|
|
||||||
|
u, err = svc.RelativeURL(pluginsMap["one"], "path/to/file.txt", "default")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://cdn.example.com/one/1.0.0/public/plugins/one/path/to/file.txt", u)
|
||||||
|
|
||||||
|
u, err = svc.RelativeURL(pluginsMap["two"], "path/to/file.txt", "default")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "public/pluginsMap/two/path/to/file.txt", u)
|
||||||
|
|
||||||
|
u, err = svc.RelativeURL(pluginsMap["two"], "default", "default")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "default", u)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -19,11 +18,13 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/plugins/config"
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
"github.com/grafana/grafana/pkg/plugins/logger"
|
"github.com/grafana/grafana/pkg/plugins/logger"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/loader/finder"
|
"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/loader/initializer"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/process"
|
"github.com/grafana/grafana/pkg/plugins/manager/process"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
"github.com/grafana/grafana/pkg/plugins/storage"
|
"github.com/grafana/grafana/pkg/plugins/storage"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
@@ -44,21 +45,25 @@ type Loader struct {
|
|||||||
pluginInitializer initializer.Initializer
|
pluginInitializer initializer.Initializer
|
||||||
signatureValidator signature.Validator
|
signatureValidator signature.Validator
|
||||||
pluginStorage storage.Manager
|
pluginStorage storage.Manager
|
||||||
|
pluginsCDN *pluginscdn.Service
|
||||||
|
assetPath *assetpath.Service
|
||||||
log log.Logger
|
log log.Logger
|
||||||
|
cfg *config.Cfg
|
||||||
|
|
||||||
errs map[string]*plugins.SignatureError
|
errs map[string]*plugins.SignatureError
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideService(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer,
|
func ProvideService(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer,
|
||||||
pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider,
|
pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider,
|
||||||
roleRegistry plugins.RoleRegistry) *Loader {
|
roleRegistry plugins.RoleRegistry, pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader {
|
||||||
return New(cfg, license, authorizer, pluginRegistry, backendProvider, process.NewManager(pluginRegistry),
|
return New(cfg, license, authorizer, pluginRegistry, backendProvider, process.NewManager(pluginRegistry),
|
||||||
storage.FileSystem(logger.NewLogger("loader.fs"), cfg.PluginsPath), roleRegistry)
|
storage.FileSystem(logger.NewLogger("loader.fs"), cfg.PluginsPath), roleRegistry, pluginsCDNService, assetPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer,
|
func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLoaderAuthorizer,
|
||||||
pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider,
|
pluginRegistry registry.Service, backendProvider plugins.BackendFactoryProvider,
|
||||||
processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry) *Loader {
|
processManager process.Service, pluginStorage storage.Manager, roleRegistry plugins.RoleRegistry,
|
||||||
|
pluginsCDNService *pluginscdn.Service, assetPath *assetpath.Service) *Loader {
|
||||||
return &Loader{
|
return &Loader{
|
||||||
pluginFinder: finder.New(),
|
pluginFinder: finder.New(),
|
||||||
pluginRegistry: pluginRegistry,
|
pluginRegistry: pluginRegistry,
|
||||||
@@ -69,6 +74,9 @@ func New(cfg *config.Cfg, license plugins.Licensing, authorizer plugins.PluginLo
|
|||||||
errs: make(map[string]*plugins.SignatureError),
|
errs: make(map[string]*plugins.SignatureError),
|
||||||
log: log.New("plugin.loader"),
|
log: log.New("plugin.loader"),
|
||||||
roleRegistry: roleRegistry,
|
roleRegistry: roleRegistry,
|
||||||
|
cfg: cfg,
|
||||||
|
pluginsCDN: pluginsCDNService,
|
||||||
|
assetPath: assetPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +89,36 @@ func (l *Loader) Load(ctx context.Context, class plugins.Class, paths []string)
|
|||||||
return l.loadPlugins(ctx, class, pluginJSONPaths)
|
return l.loadPlugins(ctx, class, pluginJSONPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Loader) createPluginsForLoading(class plugins.Class, foundPlugins foundPlugins) map[string]*plugins.Plugin {
|
||||||
|
loadedPlugins := make(map[string]*plugins.Plugin)
|
||||||
|
for pluginDir, pluginJSON := range foundPlugins {
|
||||||
|
plugin, err := l.createPluginBase(pluginJSON, class, pluginDir)
|
||||||
|
if err != nil {
|
||||||
|
l.log.Warn("Could not create plugin base", "pluginID", pluginJSON.ID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate initial signature state
|
||||||
|
var sig plugins.Signature
|
||||||
|
if l.pluginsCDN.PluginSupported(plugin.ID) {
|
||||||
|
// CDN plugins have no signature checks for now.
|
||||||
|
sig = plugins.Signature{Status: plugins.SignatureValid}
|
||||||
|
} else {
|
||||||
|
sig, err = signature.Calculate(l.log, plugin)
|
||||||
|
if err != nil {
|
||||||
|
l.log.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
|
||||||
|
|
||||||
|
loadedPlugins[plugin.PluginDir] = plugin
|
||||||
|
}
|
||||||
|
return loadedPlugins
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSONPaths []string) ([]*plugins.Plugin, error) {
|
func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSONPaths []string) ([]*plugins.Plugin, error) {
|
||||||
var foundPlugins = foundPlugins{}
|
var foundPlugins = foundPlugins{}
|
||||||
|
|
||||||
@@ -113,22 +151,8 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
|
|||||||
|
|
||||||
foundPlugins.stripDuplicates(registeredPlugins, l.log)
|
foundPlugins.stripDuplicates(registeredPlugins, l.log)
|
||||||
|
|
||||||
// calculate initial signature state
|
// create plugins structs and calculate signatures
|
||||||
loadedPlugins := make(map[string]*plugins.Plugin)
|
loadedPlugins := l.createPluginsForLoading(class, foundPlugins)
|
||||||
for pluginDir, pluginJSON := range foundPlugins {
|
|
||||||
plugin := createPluginBase(pluginJSON, class, pluginDir)
|
|
||||||
|
|
||||||
sig, err := signature.Calculate(l.log, plugin)
|
|
||||||
if err != nil {
|
|
||||||
l.log.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
|
|
||||||
|
|
||||||
loadedPlugins[plugin.PluginDir] = plugin
|
|
||||||
}
|
|
||||||
|
|
||||||
// wire up plugin dependencies
|
// wire up plugin dependencies
|
||||||
for _, plugin := range loadedPlugins {
|
for _, plugin := range loadedPlugins {
|
||||||
@@ -165,12 +189,13 @@ func (l *Loader) loadPlugins(ctx context.Context, class plugins.Class, pluginJSO
|
|||||||
// clear plugin error if a pre-existing error has since been resolved
|
// clear plugin error if a pre-existing error has since been resolved
|
||||||
delete(l.errs, plugin.ID)
|
delete(l.errs, plugin.ID)
|
||||||
|
|
||||||
// verify module.js exists for SystemJS to load
|
// verify module.js exists for SystemJS to load.
|
||||||
|
// CDN plugins can be loaded with plugin.json only, so do not warn for those.
|
||||||
if !plugin.IsRenderer() && !plugin.IsCorePlugin() {
|
if !plugin.IsRenderer() && !plugin.IsCorePlugin() {
|
||||||
module := filepath.Join(plugin.PluginDir, "module.js")
|
module := filepath.Join(plugin.PluginDir, "module.js")
|
||||||
if exists, err := fs.Exists(module); err != nil {
|
if exists, err := fs.Exists(module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if !exists {
|
} else if !exists && !l.pluginsCDN.PluginSupported(plugin.ID) {
|
||||||
l.log.Warn("Plugin missing module.js",
|
l.log.Warn("Plugin missing module.js",
|
||||||
"pluginID", plugin.ID,
|
"pluginID", plugin.ID,
|
||||||
"warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.",
|
"warning", "Missing module.js, If you loaded this plugin from git, make sure to compile it.",
|
||||||
@@ -312,28 +337,47 @@ func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error)
|
|||||||
return plugin, nil
|
return plugin, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) *plugins.Plugin {
|
func (l *Loader) createPluginBase(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) (*plugins.Plugin, error) {
|
||||||
|
baseURL, err := l.assetPath.Base(pluginJSON, class, pluginDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base url: %w", err)
|
||||||
|
}
|
||||||
|
moduleURL, err := l.assetPath.Module(pluginJSON, class, pluginDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module url: %w", err)
|
||||||
|
}
|
||||||
plugin := &plugins.Plugin{
|
plugin := &plugins.Plugin{
|
||||||
JSONData: pluginJSON,
|
JSONData: pluginJSON,
|
||||||
PluginDir: pluginDir,
|
PluginDir: pluginDir,
|
||||||
BaseURL: baseURL(pluginJSON, class, pluginDir),
|
BaseURL: baseURL,
|
||||||
Module: module(pluginJSON, class, pluginDir),
|
Module: moduleURL,
|
||||||
Class: class,
|
Class: class,
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin.SetLogger(log.New(fmt.Sprintf("plugin.%s", plugin.ID)))
|
plugin.SetLogger(log.New(fmt.Sprintf("plugin.%s", plugin.ID)))
|
||||||
setImages(plugin)
|
if err := l.setImages(plugin); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return plugin
|
return plugin, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setImages(p *plugins.Plugin) {
|
func (l *Loader) setImages(p *plugins.Plugin) error {
|
||||||
p.Info.Logos.Small = pluginLogoURL(p.Type, p.Info.Logos.Small, p.BaseURL)
|
var err error
|
||||||
p.Info.Logos.Large = pluginLogoURL(p.Type, p.Info.Logos.Large, p.BaseURL)
|
for _, dst := range []*string{&p.Info.Logos.Small, &p.Info.Logos.Large} {
|
||||||
|
*dst, err = l.assetPath.RelativeURL(p, *dst, defaultLogoPath(p.Type))
|
||||||
for i := 0; i < len(p.Info.Screenshots); i++ {
|
if err != nil {
|
||||||
p.Info.Screenshots[i].Path = evalRelativePluginURLPath(p.Info.Screenshots[i].Path, p.BaseURL, p.Type)
|
return fmt.Errorf("logo: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
for i := 0; i < len(p.Info.Screenshots); i++ {
|
||||||
|
screenshot := &p.Info.Screenshots[i]
|
||||||
|
screenshot.Path, err = l.assetPath.RelativeURL(p, screenshot.Path, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("screenshot %d relative url: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDefaultNavURL(p *plugins.Plugin) {
|
func setDefaultNavURL(p *plugins.Plugin) {
|
||||||
@@ -377,36 +421,10 @@ func configureAppChildPlugin(parent *plugins.Plugin, child *plugins.Plugin) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func defaultLogoPath(pluginType plugins.Type) string {
|
||||||
return "public/img/icn-" + string(pluginType) + ".svg"
|
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 (l *Loader) PluginErrors() []*plugins.Error {
|
func (l *Loader) PluginErrors() []*plugins.Error {
|
||||||
errs := make([]*plugins.Error, 0)
|
errs := make([]*plugins.Error, 0)
|
||||||
for _, err := range l.errs {
|
for _, err := range l.errs {
|
||||||
@@ -419,20 +437,6 @@ func (l *Loader) PluginErrors() []*plugins.Error {
|
|||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
func baseURL(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) string {
|
|
||||||
if class == plugins.Core {
|
|
||||||
return path.Join("public/app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir))
|
|
||||||
}
|
|
||||||
return path.Join("public/plugins", pluginJSON.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func module(pluginJSON plugins.JSONData, class plugins.Class, pluginDir string) string {
|
|
||||||
if class == plugins.Core {
|
|
||||||
return path.Join("app/plugins", string(pluginJSON.Type), filepath.Base(pluginDir), "module")
|
|
||||||
}
|
|
||||||
return path.Join("plugins", pluginJSON.ID, "module")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validatePluginJSON(data plugins.JSONData) error {
|
func validatePluginJSON(data plugins.JSONData) error {
|
||||||
if data.ID == "" || !data.Type.IsValid() {
|
if data.ID == "" || !data.Type.IsValid() {
|
||||||
return ErrInvalidPluginJSON
|
return ErrInvalidPluginJSON
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/google/go-cmp/cmp/cmpopts"
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -404,6 +407,61 @@ func TestLoader_Load(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Load CDN plugin",
|
||||||
|
class: plugins.External,
|
||||||
|
cfg: &config.Cfg{
|
||||||
|
PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}",
|
||||||
|
PluginSettings: setting.PluginSettings{
|
||||||
|
"grafana-worldmap-panel": {"cdn": "true"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pluginPaths: []string{"../testdata/cdn"},
|
||||||
|
want: []*plugins.Plugin{
|
||||||
|
{
|
||||||
|
JSONData: plugins.JSONData{
|
||||||
|
ID: "grafana-worldmap-panel",
|
||||||
|
Type: "panel",
|
||||||
|
Name: "Worldmap Panel",
|
||||||
|
Info: plugins.Info{
|
||||||
|
Version: "0.3.3",
|
||||||
|
Links: []plugins.InfoLink{
|
||||||
|
{Name: "Project site", URL: "https://github.com/grafana/worldmap-panel"},
|
||||||
|
{Name: "MIT License", URL: "https://github.com/grafana/worldmap-panel/blob/master/LICENSE"},
|
||||||
|
},
|
||||||
|
Logos: plugins.Logos{
|
||||||
|
// Path substitution
|
||||||
|
Small: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg",
|
||||||
|
Large: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap_logo.svg",
|
||||||
|
},
|
||||||
|
Screenshots: []plugins.Screenshots{
|
||||||
|
{
|
||||||
|
Name: "World",
|
||||||
|
Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-world.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "USA",
|
||||||
|
Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-usa.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Light Theme",
|
||||||
|
Path: "https://cdn.example.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/images/worldmap-light-theme.png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Dependencies: plugins.Dependencies{
|
||||||
|
GrafanaVersion: "3.x.x",
|
||||||
|
Plugins: []plugins.Dependency{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PluginDir: filepath.Join(parentDir, "testdata/cdn/plugin"),
|
||||||
|
Class: plugins.External,
|
||||||
|
Signature: plugins.SignatureValid,
|
||||||
|
BaseURL: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel",
|
||||||
|
Module: "plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
reg := fakes.NewFakePluginRegistry()
|
reg := fakes.NewFakePluginRegistry()
|
||||||
@@ -1320,9 +1378,10 @@ func Test_setPathsBasedOnApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader {
|
func newLoader(cfg *config.Cfg, cbs ...func(loader *Loader)) *Loader {
|
||||||
|
cdn := pluginscdn.ProvideService(cfg)
|
||||||
l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(),
|
l := New(cfg, &fakes.FakeLicensingService{}, signature.NewUnsignedAuthorizer(cfg), fakes.NewFakePluginRegistry(),
|
||||||
fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakePluginStorage(),
|
fakes.NewFakeBackendProcessProvider(), fakes.NewFakeProcessManager(), fakes.NewFakePluginStorage(),
|
||||||
fakes.NewFakeRoleRegistry())
|
fakes.NewFakeRoleRegistry(), cdn, assetpath.ProvideService(cdn))
|
||||||
|
|
||||||
for _, cb := range cbs {
|
for _, cb := range cbs {
|
||||||
cb(l)
|
cb(l)
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
|
|
||||||
"github.com/grafana/grafana-azure-sdk-go/azsettings"
|
"github.com/grafana/grafana-azure-sdk-go/azsettings"
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||||
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
|
||||||
@@ -110,10 +113,12 @@ func TestIntegrationPluginManager(t *testing.T) {
|
|||||||
|
|
||||||
pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg)
|
pCfg := config.ProvideConfig(setting.ProvideProvider(cfg), cfg)
|
||||||
reg := registry.ProvideService()
|
reg := registry.ProvideService()
|
||||||
|
cdn := pluginscdn.ProvideService(pCfg)
|
||||||
|
|
||||||
lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg})
|
lic := plicensing.ProvideLicensing(cfg, &licensing.OSSLicensingService{Cfg: cfg})
|
||||||
l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg),
|
l := loader.ProvideService(pCfg, lic, signature.NewUnsignedAuthorizer(pCfg),
|
||||||
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry())
|
reg, provider.ProvideService(coreRegistry), fakes.NewFakeRoleRegistry(),
|
||||||
|
cdn, assetpath.ProvideService(cdn))
|
||||||
ps, err := store.ProvideService(cfg, pCfg, reg, l)
|
ps, err := store.ProvideService(cfg, pCfg, reg, l)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ func NewValidator(authorizer plugins.PluginLoaderAuthorizer) Validator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
|
func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
|
||||||
if plugin.Signature == plugins.SignatureValid {
|
if plugin.Signature.IsValid() {
|
||||||
s.log.Debug("Plugin has valid signature", "id", plugin.ID)
|
s.log.Debug("Plugin has valid signature", "id", plugin.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a plugin is nested within another, create links to each other to inherit signature details
|
// If a plugin is nested within another, create links to each other to inherit signature details
|
||||||
if plugin.Parent != nil {
|
if plugin.Parent != nil {
|
||||||
if plugin.IsCorePlugin() || plugin.Signature == plugins.SignatureInternal {
|
if plugin.IsCorePlugin() || plugin.Signature.IsInternal() {
|
||||||
s.log.Debug("Not setting descendant plugin's signature to that of root since it's core or internal",
|
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())
|
"plugin", plugin.ID, "signature", plugin.Signature, "isCore", plugin.IsCorePlugin())
|
||||||
} else {
|
} else {
|
||||||
@@ -34,7 +34,7 @@ func (s *Validator) Validate(plugin *plugins.Plugin) *plugins.SignatureError {
|
|||||||
plugin.Signature = plugin.Parent.Signature
|
plugin.Signature = plugin.Parent.Signature
|
||||||
plugin.SignatureType = plugin.Parent.SignatureType
|
plugin.SignatureType = plugin.Parent.SignatureType
|
||||||
plugin.SignatureOrg = plugin.Parent.SignatureOrg
|
plugin.SignatureOrg = plugin.Parent.SignatureOrg
|
||||||
if plugin.Signature == plugins.SignatureValid {
|
if plugin.Signature.IsValid() {
|
||||||
s.log.Debug("Plugin has valid signature (inherited from root)", "id", plugin.ID)
|
s.log.Debug("Plugin has valid signature (inherited from root)", "id", plugin.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
40
pkg/plugins/manager/testdata/cdn/plugin/plugin.json
vendored
Normal file
40
pkg/plugins/manager/testdata/cdn/plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"type": "panel",
|
||||||
|
"name": "Worldmap Panel",
|
||||||
|
"id": "grafana-worldmap-panel",
|
||||||
|
"info": {
|
||||||
|
"logos": {
|
||||||
|
"small": "images/worldmap_logo.svg",
|
||||||
|
"large": "images/worldmap_logo.svg"
|
||||||
|
},
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"name": "Project site",
|
||||||
|
"url": "https://github.com/grafana/worldmap-panel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MIT License",
|
||||||
|
"url": "https://github.com/grafana/worldmap-panel/blob/master/LICENSE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"name": "World",
|
||||||
|
"path": "images/worldmap-world.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "USA",
|
||||||
|
"path": "images/worldmap-usa.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Light Theme",
|
||||||
|
"path": "images/worldmap-light-theme.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": "0.3.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"grafanaVersion": "3.x.x",
|
||||||
|
"plugins": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,10 @@ func TestParsePluginTestdata(t *testing.T) {
|
|||||||
"disallowed-cue-import": {
|
"disallowed-cue-import": {
|
||||||
err: ErrDisallowedCUEImport,
|
err: ErrDisallowedCUEImport,
|
||||||
},
|
},
|
||||||
|
"cdn": {
|
||||||
|
rootid: "grafana-worldmap-panel",
|
||||||
|
subpath: "plugin",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
staticRootPath, err := filepath.Abs("../manager/testdata")
|
staticRootPath, err := filepath.Abs("../manager/testdata")
|
||||||
|
|||||||
80
pkg/plugins/pluginscdn/pluginscdn.go
Normal file
80
pkg/plugins/pluginscdn/pluginscdn.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package pluginscdn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// systemJSCDNKeyword is the path prefix used by system.js to identify the plugins CDN.
|
||||||
|
systemJSCDNKeyword = "plugin-cdn"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrPluginNotCDN = errors.New("plugin is not a cdn plugin")
|
||||||
|
|
||||||
|
// Service provides methods for the plugins CDN.
|
||||||
|
type Service struct {
|
||||||
|
cfg *config.Cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProvideService(cfg *config.Cfg) *Service {
|
||||||
|
return &Service{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCDNURLConstructor returns a new URLConstructor for the provided plugin id and version.
|
||||||
|
// The CDN should be enabled for the plugin, otherwise the returned URLConstructor will have
|
||||||
|
// and invalid base url.
|
||||||
|
func (s *Service) NewCDNURLConstructor(pluginID, pluginVersion string) URLConstructor {
|
||||||
|
return URLConstructor{
|
||||||
|
cdnURLTemplate: s.cfg.PluginsCDNURLTemplate,
|
||||||
|
pluginID: pluginID,
|
||||||
|
pluginVersion: pluginVersion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled returns true if the plugins cdn is enabled.
|
||||||
|
func (s *Service) IsEnabled() bool {
|
||||||
|
return s.cfg.PluginsCDNURLTemplate != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginSupported returns true if the CDN is enabled in the config and if the specified plugin ID has CDN enabled.
|
||||||
|
func (s *Service) PluginSupported(pluginID string) bool {
|
||||||
|
return s.IsEnabled() && s.cfg.PluginSettings[pluginID]["cdn"] != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseURL returns the absolute base URL of the plugins CDN.
|
||||||
|
// If the plugins CDN is disabled, it returns an empty string.
|
||||||
|
func (s *Service) BaseURL() (string, error) {
|
||||||
|
if !s.IsEnabled() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s.cfg.PluginsCDNURLTemplate)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("url parse: %w", err)
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemJSAssetPath returns a system-js path for the specified asset on the plugins CDN.
|
||||||
|
// It replaces the base path of the CDN with systemJSCDNKeyword.
|
||||||
|
// If assetPath is an empty string, the base path for the plugin is returned.
|
||||||
|
func (s *Service) SystemJSAssetPath(pluginID, pluginVersion, assetPath string) (string, error) {
|
||||||
|
u, err := s.NewCDNURLConstructor(pluginID, pluginVersion).Path(assetPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return path.Join(systemJSCDNKeyword, u.Path), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetURL returns the URL of a CDN asset for a CDN plugin. If the specified plugin is not a CDN plugin,
|
||||||
|
// it returns ErrPluginNotCDN.
|
||||||
|
func (s *Service) AssetURL(pluginID, pluginVersion, assetPath string) (string, error) {
|
||||||
|
if !s.PluginSupported(pluginID) {
|
||||||
|
return "", ErrPluginNotCDN
|
||||||
|
}
|
||||||
|
return s.NewCDNURLConstructor(pluginID, pluginVersion).StringPath(assetPath)
|
||||||
|
}
|
||||||
49
pkg/plugins/pluginscdn/pluginscdn_test.go
Normal file
49
pkg/plugins/pluginscdn/pluginscdn_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package pluginscdn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestService(t *testing.T) {
|
||||||
|
svc := ProvideService(&config.Cfg{
|
||||||
|
PluginsCDNURLTemplate: "https://cdn.example.com/{id}/{version}/public/plugins/{id}/{assetPath}",
|
||||||
|
PluginSettings: map[string]map[string]string{
|
||||||
|
"one": {"cdn": "true"},
|
||||||
|
"two": {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IsCDNPlugin", func(t *testing.T) {
|
||||||
|
require.True(t, svc.PluginSupported("one"))
|
||||||
|
require.False(t, svc.PluginSupported("two"))
|
||||||
|
require.False(t, svc.PluginSupported("unknown"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CDNBaseURL", func(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
cfgURL string
|
||||||
|
expBaseURL string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
cfgURL: "https://grafana-assets.grafana.net/plugin-cdn-test/plugin-cdn/{id}/{version}/public/plugins/{id}/{assetPath}",
|
||||||
|
expBaseURL: "https://grafana-assets.grafana.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
cfgURL: "",
|
||||||
|
expBaseURL: "",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
u, err := ProvideService(&config.Cfg{PluginsCDNURLTemplate: c.cfgURL}).BaseURL()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, c.expBaseURL, u)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
61
pkg/plugins/pluginscdn/url_constructor.go
Normal file
61
pkg/plugins/pluginscdn/url_constructor.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package pluginscdn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// URLConstructor is a struct that can build CDN URLs for plugins on a remote CDN.
|
||||||
|
type URLConstructor struct {
|
||||||
|
// cdnURLTemplate is absolute base url of the CDN. This string will be formatted
|
||||||
|
// according to the rules specified in the Path method.
|
||||||
|
cdnURLTemplate string
|
||||||
|
|
||||||
|
// pluginID is the ID of the plugin.
|
||||||
|
pluginID string
|
||||||
|
|
||||||
|
// pluginVersion is the version of the plugin.
|
||||||
|
pluginVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns a new *url.URL that points to an asset file for the CDN, plugin and plugin version
|
||||||
|
// specified by the current URLConstructor.
|
||||||
|
//
|
||||||
|
// c.cdnURLTemplate is used to build the string, the following substitutions are performed in it:
|
||||||
|
//
|
||||||
|
// - {id} -> plugin id
|
||||||
|
//
|
||||||
|
// - {version} -> plugin version
|
||||||
|
//
|
||||||
|
// - {assetPath} -> assetPath
|
||||||
|
//
|
||||||
|
// The asset Path is sanitized via path.Clean (double slashes are removed, "../" is resolved, etc).
|
||||||
|
//
|
||||||
|
// The returned URL will be for a file, so it won't have a trailing slash.
|
||||||
|
func (c URLConstructor) Path(assetPath string) (*url.URL, error) {
|
||||||
|
u, err := url.Parse(
|
||||||
|
strings.TrimRight(
|
||||||
|
strings.NewReplacer(
|
||||||
|
"{id}", c.pluginID,
|
||||||
|
"{version}", c.pluginVersion,
|
||||||
|
"{assetPath}", strings.Trim(path.Clean("/"+assetPath+"/"), "/"),
|
||||||
|
).Replace(c.cdnURLTemplate),
|
||||||
|
"/",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("url parse: %w", err)
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringPath is like Path, but it returns the absolute URL as a string rather than *url.URL.
|
||||||
|
func (c URLConstructor) StringPath(assetPath string) (string, error) {
|
||||||
|
u, err := c.Path(assetPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
34
pkg/plugins/pluginscdn/url_constructor_test.go
Normal file
34
pkg/plugins/pluginscdn/url_constructor_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package pluginscdn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestURLConstructor_StringURLFor(t *testing.T) {
|
||||||
|
uc := URLConstructor{
|
||||||
|
cdnURLTemplate: "https://the.cdn/{id}/{version}/{assetPath}",
|
||||||
|
pluginID: "the-plugin",
|
||||||
|
pluginVersion: "0.1",
|
||||||
|
}
|
||||||
|
type tc struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
exp string
|
||||||
|
}
|
||||||
|
for _, c := range []tc{
|
||||||
|
{"simple", "file.txt", "https://the.cdn/the-plugin/0.1/file.txt"},
|
||||||
|
{"multiple", "some/path/to/file.txt", "https://the.cdn/the-plugin/0.1/some/path/to/file.txt"},
|
||||||
|
{"path traversal", "some/../to/file.txt", "https://the.cdn/the-plugin/0.1/to/file.txt"},
|
||||||
|
{"above root", "../../../../../file.txt", "https://the.cdn/the-plugin/0.1/file.txt"},
|
||||||
|
{"multiple slashes", "some/////file.txt", "https://the.cdn/the-plugin/0.1/some/file.txt"},
|
||||||
|
{"dots", "some/././././file.txt", "https://the.cdn/the-plugin/0.1/some/file.txt"},
|
||||||
|
} {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
u, err := uc.StringPath(c.path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, c.exp, u)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/plugins/manager"
|
"github.com/grafana/grafana/pkg/plugins/manager"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/client"
|
"github.com/grafana/grafana/pkg/plugins/manager/client"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/process"
|
"github.com/grafana/grafana/pkg/plugins/manager/process"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/store"
|
"github.com/grafana/grafana/pkg/plugins/manager/store"
|
||||||
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
"github.com/grafana/grafana/pkg/plugins/plugincontext"
|
||||||
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
"github.com/grafana/grafana/pkg/plugins/repo"
|
"github.com/grafana/grafana/pkg/plugins/repo"
|
||||||
"github.com/grafana/grafana/pkg/services/oauthtoken"
|
"github.com/grafana/grafana/pkg/services/oauthtoken"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware"
|
||||||
@@ -34,6 +36,8 @@ var WireSet = wire.NewSet(
|
|||||||
process.ProvideService,
|
process.ProvideService,
|
||||||
wire.Bind(new(process.Service), new(*process.Manager)),
|
wire.Bind(new(process.Service), new(*process.Manager)),
|
||||||
coreplugin.ProvideCoreRegistry,
|
coreplugin.ProvideCoreRegistry,
|
||||||
|
pluginscdn.ProvideService,
|
||||||
|
assetpath.ProvideService,
|
||||||
loader.ProvideService,
|
loader.ProvideService,
|
||||||
wire.Bind(new(loader.Service), new(*loader.Loader)),
|
wire.Bind(new(loader.Service), new(*loader.Loader)),
|
||||||
wire.Bind(new(plugins.ErrorResolver), new(*loader.Loader)),
|
wire.Bind(new(plugins.ErrorResolver), new(*loader.Loader)),
|
||||||
|
|||||||
@@ -276,6 +276,8 @@ type Cfg struct {
|
|||||||
PluginAdminEnabled bool
|
PluginAdminEnabled bool
|
||||||
PluginAdminExternalManageEnabled bool
|
PluginAdminExternalManageEnabled bool
|
||||||
|
|
||||||
|
PluginsCDNURLTemplate string
|
||||||
|
|
||||||
// Panels
|
// Panels
|
||||||
DisableSanitizeHtml bool
|
DisableSanitizeHtml bool
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func extractPluginSettings(sections []*ini.Section) PluginSettings {
|
|||||||
|
|
||||||
func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
|
func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
|
||||||
pluginsSection := iniFile.Section("plugins")
|
pluginsSection := iniFile.Section("plugins")
|
||||||
|
|
||||||
cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false)
|
cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false)
|
||||||
cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false)
|
cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false)
|
||||||
cfg.PluginSettings = extractPluginSettings(iniFile.Sections())
|
cfg.PluginSettings = extractPluginSettings(iniFile.Sections())
|
||||||
@@ -47,5 +48,8 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
|
|||||||
cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug)
|
cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugins CDN settings
|
||||||
|
cfg.PluginsCDNURLTemplate = strings.TrimRight(pluginsSection.Key("cdn_base_url").MustString(""), "/")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ export class AngularApp {
|
|||||||
'$filterProvider',
|
'$filterProvider',
|
||||||
'$httpProvider',
|
'$httpProvider',
|
||||||
'$provide',
|
'$provide',
|
||||||
|
'$sceDelegateProvider',
|
||||||
(
|
(
|
||||||
$controllerProvider: angular.IControllerProvider,
|
$controllerProvider: angular.IControllerProvider,
|
||||||
$compileProvider: angular.ICompileProvider,
|
$compileProvider: angular.ICompileProvider,
|
||||||
$filterProvider: angular.IFilterProvider,
|
$filterProvider: angular.IFilterProvider,
|
||||||
$httpProvider: angular.IHttpProvider,
|
$httpProvider: angular.IHttpProvider,
|
||||||
$provide: angular.auto.IProvideService
|
$provide: angular.auto.IProvideService,
|
||||||
|
$sceDelegateProvider: angular.ISCEDelegateProvider
|
||||||
) => {
|
) => {
|
||||||
if (config.buildInfo.env !== 'development') {
|
if (config.buildInfo.env !== 'development') {
|
||||||
$compileProvider.debugInfoEnabled(false);
|
$compileProvider.debugInfoEnabled(false);
|
||||||
@@ -56,6 +58,10 @@ export class AngularApp {
|
|||||||
|
|
||||||
$httpProvider.useApplyAsync(true);
|
$httpProvider.useApplyAsync(true);
|
||||||
|
|
||||||
|
if (Boolean(config.pluginsCDNBaseURL)) {
|
||||||
|
$sceDelegateProvider.trustedResourceUrlList(['self', `${config.pluginsCDNBaseURL}/**`]);
|
||||||
|
}
|
||||||
|
|
||||||
this.registerFunctions.controller = $controllerProvider.register;
|
this.registerFunctions.controller = $controllerProvider.register;
|
||||||
this.registerFunctions.directive = $compileProvider.directive;
|
this.registerFunctions.directive = $compileProvider.directive;
|
||||||
this.registerFunctions.factory = $provide.factory;
|
this.registerFunctions.factory = $provide.factory;
|
||||||
|
|||||||
18
public/app/angular/components/plugin_component.test.ts
Normal file
18
public/app/angular/components/plugin_component.test.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { relativeTemplateUrlToCDN } from './plugin_component';
|
||||||
|
|
||||||
|
describe('Plugin Component', () => {
|
||||||
|
describe('relativeTemplateUrlToCDN()', () => {
|
||||||
|
it('should create a proper path', () => {
|
||||||
|
config.pluginsCDNBaseURL = 'http://my-host.com';
|
||||||
|
|
||||||
|
const templateUrl = 'partials/module.html';
|
||||||
|
const baseUrl = 'plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel';
|
||||||
|
const expectedUrl =
|
||||||
|
'http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/partials/module.html';
|
||||||
|
|
||||||
|
expect(relativeTemplateUrlToCDN(templateUrl, baseUrl)).toBe(expectedUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,20 @@ import config from 'app/core/config';
|
|||||||
import { importPanelPlugin } from '../../features/plugins/importPanelPlugin';
|
import { importPanelPlugin } from '../../features/plugins/importPanelPlugin';
|
||||||
import { importDataSourcePlugin, importAppPlugin } from '../../features/plugins/plugin_loader';
|
import { importDataSourcePlugin, importAppPlugin } from '../../features/plugins/plugin_loader';
|
||||||
|
|
||||||
|
export function relativeTemplateUrlToCDN(templateUrl: string, baseUrl: string) {
|
||||||
|
if (!templateUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the templateUrl may have already been updated with the hostname
|
||||||
|
if (templateUrl.startsWith(config.pluginsCDNBaseURL)) {
|
||||||
|
return templateUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// use the 'plugin-cdn' key to load via cdn
|
||||||
|
return `${baseUrl.replace('plugin-cdn/', `${config.pluginsCDNBaseURL}/`)}/${templateUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
coreModule.directive('pluginComponent', ['$compile', '$http', '$templateCache', '$location', pluginDirectiveLoader]);
|
coreModule.directive('pluginComponent', ['$compile', '$http', '$templateCache', '$location', pluginDirectiveLoader]);
|
||||||
|
|
||||||
function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $location: ILocationService) {
|
function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $location: ILocationService) {
|
||||||
@@ -31,12 +45,17 @@ function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $
|
|||||||
if (templateUrl.indexOf('public') === 0) {
|
if (templateUrl.indexOf('public') === 0) {
|
||||||
return templateUrl;
|
return templateUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseUrl + '/' + templateUrl;
|
return baseUrl + '/' + templateUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPluginComponentDirective(options: any) {
|
function getPluginComponentDirective(options: any) {
|
||||||
// handle relative template urls for plugin templates
|
if (options.baseUrl.includes('plugin-cdn')) {
|
||||||
options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl);
|
options.Component.templateUrl = relativeTemplateUrlToCDN(options.Component.templateUrl, options.baseUrl);
|
||||||
|
} else {
|
||||||
|
// handle relative template urls for plugin templates
|
||||||
|
options.Component.templateUrl = relativeTemplateUrlToAbs(options.Component.templateUrl, options.baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
return {
|
return {
|
||||||
@@ -86,13 +105,17 @@ function pluginDirectiveLoader($compile: any, $http: any, $templateCache: any, $
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (panelInfo) {
|
if (panelInfo) {
|
||||||
PanelCtrl.templateUrl = relativeTemplateUrlToAbs(PanelCtrl.templateUrl, panelInfo.baseUrl);
|
if (panelInfo.baseUrl.includes('plugin-cdn')) {
|
||||||
|
PanelCtrl.templateUrl = relativeTemplateUrlToCDN(PanelCtrl.templateUrl, panelInfo.baseUrl);
|
||||||
|
} else {
|
||||||
|
PanelCtrl.templateUrl = relativeTemplateUrlToAbs(PanelCtrl.templateUrl, panelInfo.baseUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCtrl.templatePromise = getTemplate(PanelCtrl).then((template: any) => {
|
PanelCtrl.templatePromise = getTemplate(PanelCtrl).then((template: any) => {
|
||||||
PanelCtrl.templateUrl = null;
|
PanelCtrl.templateUrl = null;
|
||||||
PanelCtrl.template = `<grafana-panel ctrl="ctrl" class="panel-height-helper">${template}</grafana-panel>`;
|
PanelCtrl.template = `<grafana-panel ctrl="ctrl" class="panel-height-helper">${template}</grafana-panel>`;
|
||||||
return componentInfo;
|
return { ...componentInfo, baseUrl: panelInfo.baseUrl };
|
||||||
});
|
});
|
||||||
|
|
||||||
return PanelCtrl.templatePromise;
|
return PanelCtrl.templatePromise;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getBackendSrv, isFetchError } from '@grafana/runtime';
|
|||||||
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
|
import { importPanelPlugin } from 'app/features/plugins/importPanelPlugin';
|
||||||
import { StoreState, ThunkResult } from 'app/types';
|
import { StoreState, ThunkResult } from 'app/types';
|
||||||
|
|
||||||
import { invalidatePluginInCache } from '../../pluginCacheBuster';
|
import { invalidatePluginInCache } from '../../systemjsPlugins/pluginCacheBuster';
|
||||||
import {
|
import {
|
||||||
getRemotePlugins,
|
getRemotePlugins,
|
||||||
getPluginErrors,
|
getPluginErrors,
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import * as ticks from 'app/core/utils/ticks';
|
|||||||
import { GenericDataSourcePlugin } from '../datasources/types';
|
import { GenericDataSourcePlugin } from '../datasources/types';
|
||||||
|
|
||||||
import builtInPlugins from './built_in_plugins';
|
import builtInPlugins from './built_in_plugins';
|
||||||
import { locateWithCache, registerPluginInCache } from './pluginCacheBuster';
|
import { locateFromCDN, translateForCDN } from './systemjsPlugins/pluginCDN';
|
||||||
|
import { fetchCSS, locateCSS } from './systemjsPlugins/pluginCSS';
|
||||||
|
import { locateWithCache, registerPluginInCache } from './systemjsPlugins/pluginCacheBuster';
|
||||||
|
|
||||||
// Help the 6.4 to 6.5 migration
|
// Help the 6.4 to 6.5 migration
|
||||||
// The base classes were moved from @grafana/ui to @grafana/data
|
// The base classes were moved from @grafana/ui to @grafana/data
|
||||||
@@ -43,7 +45,12 @@ grafanaUI.DataSourcePlugin = grafanaData.DataSourcePlugin;
|
|||||||
grafanaUI.AppPlugin = grafanaData.AppPlugin;
|
grafanaUI.AppPlugin = grafanaData.AppPlugin;
|
||||||
grafanaUI.DataSourceApi = grafanaData.DataSourceApi;
|
grafanaUI.DataSourceApi = grafanaData.DataSourceApi;
|
||||||
|
|
||||||
|
grafanaRuntime.SystemJS.registry.set('css', grafanaRuntime.SystemJS.newModule({ locate: locateCSS, fetch: fetchCSS }));
|
||||||
grafanaRuntime.SystemJS.registry.set('plugin-loader', grafanaRuntime.SystemJS.newModule({ locate: locateWithCache }));
|
grafanaRuntime.SystemJS.registry.set('plugin-loader', grafanaRuntime.SystemJS.newModule({ locate: locateWithCache }));
|
||||||
|
grafanaRuntime.SystemJS.registry.set(
|
||||||
|
'cdn-loader',
|
||||||
|
grafanaRuntime.SystemJS.newModule({ locate: locateFromCDN, translate: translateForCDN })
|
||||||
|
);
|
||||||
|
|
||||||
grafanaRuntime.SystemJS.config({
|
grafanaRuntime.SystemJS.config({
|
||||||
baseURL: 'public',
|
baseURL: 'public',
|
||||||
@@ -52,10 +59,12 @@ grafanaRuntime.SystemJS.config({
|
|||||||
plugins: {
|
plugins: {
|
||||||
defaultExtension: 'js',
|
defaultExtension: 'js',
|
||||||
},
|
},
|
||||||
|
'plugin-cdn': {
|
||||||
|
defaultExtension: 'js',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
map: {
|
map: {
|
||||||
text: 'vendor/plugin-text/text.js',
|
text: 'vendor/plugin-text/text.js',
|
||||||
css: 'vendor/plugin-css/css.js',
|
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
'/*': {
|
'/*': {
|
||||||
@@ -63,6 +72,14 @@ grafanaRuntime.SystemJS.config({
|
|||||||
authorization: true,
|
authorization: true,
|
||||||
loader: 'plugin-loader',
|
loader: 'plugin-loader',
|
||||||
},
|
},
|
||||||
|
'*.css': {
|
||||||
|
loader: 'css',
|
||||||
|
},
|
||||||
|
'plugin-cdn/*': {
|
||||||
|
esModule: true,
|
||||||
|
authorization: false,
|
||||||
|
loader: 'cdn-loader',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
105
public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts
Normal file
105
public/app/features/plugins/systemjsPlugins/pluginCDN.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { translateForCDN, extractPluginNameVersionFromUrl } from './pluginCDN';
|
||||||
|
describe('Plugin CDN', () => {
|
||||||
|
describe('translateForCDN', () => {
|
||||||
|
const load = {
|
||||||
|
name: 'http://localhost:3000/public/plugin-cdn/grafana-worldmap-panel/0.3.3/grafana-worldmap-panel/module.js',
|
||||||
|
address: 'http://my-host.com/grafana-worldmap-panel/0.3.3/grafana-worldmap-panel/module.js',
|
||||||
|
source: 'public/plugins/grafana-worldmap-panel/template.html',
|
||||||
|
metadata: {
|
||||||
|
extension: '',
|
||||||
|
deps: [],
|
||||||
|
format: 'amd',
|
||||||
|
loader: 'cdn-loader',
|
||||||
|
encapsulateGlobal: false,
|
||||||
|
cjsRequireDetection: true,
|
||||||
|
cjsDeferDepsExecute: false,
|
||||||
|
esModule: true,
|
||||||
|
authorization: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
config.pluginsCDNBaseURL = 'http://my-host.com';
|
||||||
|
|
||||||
|
it('should update the default local path to use the CDN path', () => {
|
||||||
|
const translatedLoad = translateForCDN({
|
||||||
|
...load,
|
||||||
|
source: 'public/plugins/grafana-worldmap-panel/template.html',
|
||||||
|
});
|
||||||
|
expect(translatedLoad).toBe(
|
||||||
|
'http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace the default path in a multi-line source code', () => {
|
||||||
|
const source = `
|
||||||
|
const a = "public/plugins/grafana-worldmap-panel/template.html";
|
||||||
|
const img = "<img src='public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
|
||||||
|
`;
|
||||||
|
const expectedSource = `
|
||||||
|
const a = "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html";
|
||||||
|
const img = "<img src='http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
|
||||||
|
`;
|
||||||
|
const translatedLoad = translateForCDN({ ...load, source });
|
||||||
|
expect(translatedLoad).toBe(expectedSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cater for local paths starting with a slash', () => {
|
||||||
|
const source = `
|
||||||
|
const a = "/public/plugins/grafana-worldmap-panel/template.html";
|
||||||
|
const img = "<img src='public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
|
||||||
|
`;
|
||||||
|
const expectedSource = `
|
||||||
|
const a = "http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/template.html";
|
||||||
|
const img = "<img src='http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/myimage.jpg'>";
|
||||||
|
`;
|
||||||
|
const translatedLoad = translateForCDN({ ...load, source });
|
||||||
|
expect(translatedLoad).toBe(expectedSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cater for a particular path', () => {
|
||||||
|
const source = `
|
||||||
|
.getJSON(
|
||||||
|
"public/plugins/grafana-worldmap-panel/data/" +
|
||||||
|
this.panel.locationData +
|
||||||
|
".json"
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const expectedSource = `
|
||||||
|
.getJSON(
|
||||||
|
"http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/data/" +
|
||||||
|
this.panel.locationData +
|
||||||
|
".json"
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const translatedLoad = translateForCDN({ ...load, source });
|
||||||
|
expect(translatedLoad).toBe(expectedSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should replace sourcemap locations', () => {
|
||||||
|
const source = `
|
||||||
|
Zn(t,e)},t.Rectangle=ui,t.rectangle=function(t,e){return new ui(t,e)},t.Map=He,t.map=function(t,e){return new He(t,e)}}(e)}])});
|
||||||
|
//# sourceMappingURL=module.js.map
|
||||||
|
`;
|
||||||
|
const expectedSource = `
|
||||||
|
Zn(t,e)},t.Rectangle=ui,t.rectangle=function(t,e){return new ui(t,e)},t.Map=He,t.map=function(t,e){return new He(t,e)}}(e)}])});
|
||||||
|
//# sourceMappingURL=http://my-host.com/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js.map
|
||||||
|
`;
|
||||||
|
const translatedLoad = translateForCDN({ ...load, source });
|
||||||
|
expect(translatedLoad).toBe(expectedSource);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractPluginNameVersionFromUrl', () => {
|
||||||
|
it('should extract the plugin name and version from a path', () => {
|
||||||
|
const source =
|
||||||
|
'http://localhost:3000/public/plugin-cdn/grafana-worldmap-panel/0.3.3/public/plugins/grafana-worldmap-panel/module.js';
|
||||||
|
const expected = {
|
||||||
|
name: 'grafana-worldmap-panel',
|
||||||
|
version: '0.3.3',
|
||||||
|
};
|
||||||
|
const expectedExtractedPluginDeets = extractPluginNameVersionFromUrl(source);
|
||||||
|
expect(expectedExtractedPluginDeets).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
public/app/features/plugins/systemjsPlugins/pluginCDN.ts
Normal file
29
public/app/features/plugins/systemjsPlugins/pluginCDN.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import type { SystemJSLoad } from './types';
|
||||||
|
|
||||||
|
export function extractPluginNameVersionFromUrl(address: string) {
|
||||||
|
const path = new URL(address).pathname;
|
||||||
|
const match = path.split('/');
|
||||||
|
return { name: match[3], version: match[4] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function locateFromCDN(load: SystemJSLoad) {
|
||||||
|
const { address } = load;
|
||||||
|
const pluginPath = address.split('/public/plugin-cdn/');
|
||||||
|
return `${config.pluginsCDNBaseURL}/${pluginPath[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateForCDN(load: SystemJSLoad) {
|
||||||
|
const { name, version } = extractPluginNameVersionFromUrl(load.name);
|
||||||
|
const baseAddress = `${config.pluginsCDNBaseURL}/${name}/${version}`;
|
||||||
|
|
||||||
|
load.source = load.source.replace(/(\/?)(public\/plugins)/g, `${baseAddress}/$2`);
|
||||||
|
load.source = load.source.replace(/(["|'])(plugins\/.+.css)(["|'])/g, `$1${baseAddress}/public/$2$3`);
|
||||||
|
load.source = load.source.replace(
|
||||||
|
/(\/\/#\ssourceMappingURL=)(.+)\.map/g,
|
||||||
|
`$1${baseAddress}/public/plugins/${name}/$2.map`
|
||||||
|
);
|
||||||
|
|
||||||
|
return load.source;
|
||||||
|
}
|
||||||
75
public/app/features/plugins/systemjsPlugins/pluginCSS.ts
Normal file
75
public/app/features/plugins/systemjsPlugins/pluginCSS.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import type { SystemJSLoad } from './types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Locate: Overrides the location of the plugin resource
|
||||||
|
Plugins that import css use relative paths in Systemjs.register dependency list.
|
||||||
|
Rather than attempt to resolve it in the pluginCDN systemjs plugin let SystemJS resolve it to origin
|
||||||
|
then we can replace the "baseUrl" with the "cdnHost".
|
||||||
|
*/
|
||||||
|
export function locateCSS(load: SystemJSLoad) {
|
||||||
|
if (load.metadata.loader === 'cdn-loader' && load.address.startsWith(`${location.origin}/public/plugin-cdn`)) {
|
||||||
|
load.address = load.address.replace(`${location.origin}/public/plugin-cdn`, config.pluginsCDNBaseURL);
|
||||||
|
}
|
||||||
|
return load.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fetch: Called with second argument representing default fetch function, has full control of fetch output.
|
||||||
|
Plugins that have external CSS will use this plugin to load their custom styles
|
||||||
|
*/
|
||||||
|
export function fetchCSS(load: SystemJSLoad) {
|
||||||
|
const links = document.getElementsByTagName('link');
|
||||||
|
const linkHrefs: string[] = Array.from(links).map((link) => link.href);
|
||||||
|
|
||||||
|
// dont reload styles loaded in the head
|
||||||
|
if (linkHrefs.includes(load.address)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadCSS(load.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bust = '?_cache=' + Date.now();
|
||||||
|
const waitSeconds = 100;
|
||||||
|
|
||||||
|
function loadCSS(url: string) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
const timeout = setTimeout(function () {
|
||||||
|
reject('Unable to load CSS');
|
||||||
|
}, waitSeconds * 1000);
|
||||||
|
const _callback = function (error?: string | Error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
link.onload = link.onerror = noop;
|
||||||
|
setTimeout(function () {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve('');
|
||||||
|
}
|
||||||
|
}, 7);
|
||||||
|
};
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.type = 'text/css';
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = url;
|
||||||
|
|
||||||
|
// Don't cache bust plugins loaded from cdn.
|
||||||
|
if (!link.href.startsWith(config.pluginsCDNBaseURL)) {
|
||||||
|
link.href = link.href + bust;
|
||||||
|
}
|
||||||
|
|
||||||
|
link.onload = function () {
|
||||||
|
_callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
link.onerror = function (event) {
|
||||||
|
_callback(event instanceof ErrorEvent ? event.message : new Error('Error loading CSS file.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
document.head.appendChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from '../pluginCacheBuster';
|
|
||||||
import * as pluginSettings from '../pluginSettings';
|
import * as pluginSettings from '../pluginSettings';
|
||||||
|
|
||||||
|
import { invalidatePluginInCache, locateWithCache, registerPluginInCache } from './pluginCacheBuster';
|
||||||
|
|
||||||
describe('PluginCacheBuster', () => {
|
describe('PluginCacheBuster', () => {
|
||||||
const now = 12345;
|
const now = 12345;
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { clearPluginSettingsCache } from './pluginSettings';
|
import { clearPluginSettingsCache } from '../pluginSettings';
|
||||||
|
|
||||||
const cache: Record<string, string> = {};
|
const cache: Record<string, string> = {};
|
||||||
const initializedAt: number = Date.now();
|
const initializedAt: number = Date.now();
|
||||||
16
public/app/features/plugins/systemjsPlugins/types.ts
Normal file
16
public/app/features/plugins/systemjsPlugins/types.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type SystemJSLoad = {
|
||||||
|
address: string;
|
||||||
|
metadata: {
|
||||||
|
authorization: boolean;
|
||||||
|
cjsDeferDepsExecute: boolean;
|
||||||
|
cjsRequireDetection: boolean;
|
||||||
|
crossOrigin?: boolean;
|
||||||
|
encapsulateGlobal: boolean;
|
||||||
|
esModule: boolean;
|
||||||
|
integrity?: string;
|
||||||
|
loader: string;
|
||||||
|
scriptLoad?: boolean;
|
||||||
|
};
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
73
public/vendor/plugin-css/css.js
vendored
73
public/vendor/plugin-css/css.js
vendored
@@ -1,73 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
var bust = '?_cache=' + Date.now();
|
|
||||||
var waitSeconds = 100;
|
|
||||||
|
|
||||||
var head = document.getElementsByTagName('head')[0];
|
|
||||||
|
|
||||||
// get all link tags in the page
|
|
||||||
var links = document.getElementsByTagName('link');
|
|
||||||
var linkHrefs = [];
|
|
||||||
for (var i = 0; i < links.length; i++) {
|
|
||||||
linkHrefs.push(links[i].href);
|
|
||||||
}
|
|
||||||
|
|
||||||
var isWebkit = !!window.navigator.userAgent.match(/AppleWebKit\/([^ ;]*)/);
|
|
||||||
var webkitLoadCheck = function (link, callback) {
|
|
||||||
setTimeout(function () {
|
|
||||||
for (var i = 0; i < document.styleSheets.length; i++) {
|
|
||||||
var sheet = document.styleSheets[i];
|
|
||||||
if (sheet.href === link.href) {
|
|
||||||
return callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
webkitLoadCheck(link, callback);
|
|
||||||
}, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
var noop = function () { };
|
|
||||||
|
|
||||||
var loadCSS = function (url) {
|
|
||||||
return new Promise(function (resolve, reject) {
|
|
||||||
var timeout = setTimeout(function () {
|
|
||||||
reject('Unable to load CSS');
|
|
||||||
}, waitSeconds * 1000);
|
|
||||||
var _callback = function (error) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
link.onload = link.onerror = noop;
|
|
||||||
setTimeout(function () {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
resolve('');
|
|
||||||
}
|
|
||||||
}, 7);
|
|
||||||
};
|
|
||||||
var link = document.createElement('link');
|
|
||||||
link.type = 'text/css';
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = url + bust;
|
|
||||||
if (!isWebkit) {
|
|
||||||
link.onload = function () {
|
|
||||||
_callback();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
webkitLoadCheck(link, _callback);
|
|
||||||
}
|
|
||||||
link.onerror = function (event) {
|
|
||||||
_callback(event.error || new Error('Error loading CSS file.'));
|
|
||||||
};
|
|
||||||
head.appendChild(link);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.fetch = function (load) {
|
|
||||||
// dont reload styles loaded in the head
|
|
||||||
for (var i = 0; i < linkHrefs.length; i++)
|
|
||||||
if (load.address == linkHrefs[i])
|
|
||||||
return '';
|
|
||||||
return loadCSS(load.address);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user