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:
Giuseppe Guerra
2023-01-27 15:08:17 +01:00
committed by GitHub
co-authored by Will Browne Jack Westbrook
parent c931b8031e
commit af1e2d68da
35 changed files with 1139 additions and 188 deletions
+80
View 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
View 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
View 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
}
@@ -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)
})
}
}