Compare commits
7 Commits
alerting/U
...
wb/pkg-plu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6e870a3a | ||
|
|
975480f10e | ||
|
|
3445cd3378 | ||
|
|
df790a4279 | ||
|
|
f3e28cf440 | ||
|
|
f8ab6ecc79 | ||
|
|
6a955eb6d1 |
@@ -1392,6 +1392,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/mute-timings/MuteTimingForm.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/mute-timings/MuteTimingTimeInterval.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 5
|
||||
@@ -1516,6 +1521,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/MuteTimingFields.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/route-settings/RouteSettings.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
@@ -80,7 +82,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
|
||||
var pluginsAssets = passets
|
||||
if pluginsAssets == nil {
|
||||
sig := signature.ProvideService(pluginsCfg, statickey.New())
|
||||
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
|
||||
calc := modulehash.NewCalculator(pluginsCfg, registry.NewInMemory(), pluginsCDN, sig)
|
||||
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, calc)
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
@@ -714,6 +717,8 @@ func newPluginAssets() func() *pluginassets.Service {
|
||||
|
||||
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
|
||||
return func() *pluginassets.Service {
|
||||
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
|
||||
cdn := pluginscdn.ProvideService(pCfg)
|
||||
calc := modulehash.NewCalculator(pCfg, registry.NewInMemory(), cdn, signature.ProvideService(pCfg, statickey.New()))
|
||||
return pluginassets.ProvideService(pCfg, cdn, calc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginerrs"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -849,7 +850,8 @@ func Test_PluginsSettings(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{}
|
||||
pluginCDN := pluginscdn.ProvideService(pCfg)
|
||||
sig := signature.ProvideService(pCfg, statickey.New())
|
||||
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
|
||||
calc := modulehash.NewCalculator(pCfg, registry.NewInMemory(), pluginCDN, sig)
|
||||
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, calc)
|
||||
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
|
||||
hs.pluginsUpdateChecker, err = updatemanager.ProvidePluginsService(
|
||||
hs.Cfg,
|
||||
|
||||
144
pkg/plugins/pluginassets/modulehash/modulehash.go
Normal file
144
pkg/plugins/pluginassets/modulehash/modulehash.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package modulehash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/log"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
type Calculator struct {
|
||||
reg registry.Service
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
signature *signature.Signature
|
||||
log log.Logger
|
||||
|
||||
moduleHashCache sync.Map
|
||||
}
|
||||
|
||||
func NewCalculator(cfg *config.PluginManagementCfg, reg registry.Service, cdn *pluginscdn.Service, signature *signature.Signature) *Calculator {
|
||||
return &Calculator{
|
||||
cfg: cfg,
|
||||
reg: reg,
|
||||
cdn: cdn,
|
||||
signature: signature,
|
||||
log: log.New("modulehash"),
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's MANIFEST.txt file.
|
||||
// The plugin can also be a nested plugin.
|
||||
// If the plugin is unsigned, an empty string is returned.
|
||||
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
|
||||
func (c *Calculator) ModuleHash(ctx context.Context, pluginID, pluginVersion string) string {
|
||||
p, ok := c.reg.Plugin(ctx, pluginID, pluginVersion)
|
||||
if !ok {
|
||||
c.log.Error("Failed to calculate module hash as plugin is not registered", "pluginId", pluginID)
|
||||
return ""
|
||||
}
|
||||
k := c.moduleHashCacheKey(pluginID, pluginVersion)
|
||||
cachedValue, ok := c.moduleHashCache.Load(k)
|
||||
if ok {
|
||||
return cachedValue.(string)
|
||||
}
|
||||
mh, err := c.moduleHash(ctx, p, "")
|
||||
if err != nil {
|
||||
c.log.Error("Failed to calculate module hash", "pluginId", p.ID, "error", err)
|
||||
}
|
||||
c.moduleHashCache.Store(k, mh)
|
||||
return mh
|
||||
}
|
||||
|
||||
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
|
||||
// If the plugin is not a CDN plugin, the function will return an empty string.
|
||||
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
|
||||
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
|
||||
// module.js file, rather than for the provided plugin.
|
||||
func (c *Calculator) moduleHash(ctx context.Context, p *plugins.Plugin, childFSBase string) (r string, err error) {
|
||||
if !c.cfg.Features.SriChecksEnabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ignore unsigned plugins
|
||||
if !p.Signature.IsValid() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
// The module hash is contained within the parent's MANIFEST.txt file.
|
||||
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
|
||||
//
|
||||
// ```
|
||||
// "datasource/module.js": "1234567890abcdef..."
|
||||
// ```
|
||||
//
|
||||
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
|
||||
// to get the correct module hash for the nested plugin.
|
||||
if childFSBase == "" {
|
||||
childFSBase = p.FS.Base()
|
||||
}
|
||||
return c.moduleHash(ctx, p.Parent, childFSBase)
|
||||
}
|
||||
|
||||
// Only CDN plugins are supported for SRI checks.
|
||||
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
|
||||
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
|
||||
// FS plugins build before this change will have SRI mismatch issues.
|
||||
if !c.cdnEnabled(p.ID, p.FS) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest, err := c.signature.ReadPluginManifestFromFS(ctx, p.FS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if !manifest.IsV2() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var childPath string
|
||||
if childFSBase != "" {
|
||||
// Calculate the relative path of the child plugin folder from the parent plugin folder.
|
||||
childPath, err = p.FS.Rel(childFSBase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// MANIFETS.txt uses forward slashes as path separators.
|
||||
childPath = filepath.ToSlash(childPath)
|
||||
}
|
||||
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return convertHashForSRI(moduleHash)
|
||||
}
|
||||
|
||||
func (c *Calculator) cdnEnabled(pluginID string, fs plugins.FS) bool {
|
||||
return c.cdn.PluginSupported(pluginID) || fs.Type().CDN()
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) (string, error) {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex decode string: %w", err)
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
|
||||
}
|
||||
|
||||
// moduleHashCacheKey returns a unique key for the module hash cache.
|
||||
func (c *Calculator) moduleHashCacheKey(pluginId, pluginVersion string) string {
|
||||
return pluginId + ":" + pluginVersion
|
||||
}
|
||||
459
pkg/plugins/pluginassets/modulehash/modulehash_test.go
Normal file
459
pkg/plugins/pluginassets/modulehash/modulehash_test.go
Normal file
@@ -0,0 +1,459 @@
|
||||
package modulehash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
)
|
||||
|
||||
func Test_ModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
features *config.Features
|
||||
registry []*plugins.Plugin
|
||||
|
||||
// Can be used to configure plugin's fs
|
||||
// fs cdn type = loaded from CDN with no files on disk
|
||||
// fs local type = files on disk but served from CDN only if cdn=true
|
||||
plugin string
|
||||
|
||||
// When true, set cdn=true in config
|
||||
cdn bool
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "unsigned should not return module hash",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned))},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
)},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
)},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{
|
||||
newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "datasource"))),
|
||||
withParent(newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)),
|
||||
),
|
||||
},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{
|
||||
newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)),
|
||||
),
|
||||
},
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
|
||||
registry: []*plugins.Plugin{
|
||||
newPlugin(
|
||||
"child-panel",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
|
||||
withParent(newPlugin(
|
||||
"parent-datasource",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested", "datasource"))),
|
||||
withParent(newPlugin(
|
||||
"grand-parent-app",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-deeply-nested"))),
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
plugin: "child-panel",
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash from parent if it's not registered in the registry",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-no-module-js"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: pluginID,
|
||||
registry: []*plugins.Plugin{newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-no-manifest-txt"))),
|
||||
)},
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
if tc.name == "" {
|
||||
var expS string
|
||||
if tc.expModuleHash == "" {
|
||||
expS = "should not return module hash"
|
||||
} else {
|
||||
expS = "should return module hash"
|
||||
}
|
||||
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v %s", tc.features.SriChecksEnabled, tc.cdn, expS)
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pluginSettings config.PluginSettings
|
||||
if tc.cdn {
|
||||
pluginSettings = config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
parentPluginID: map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
"grand-parent-app": map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
features := tc.features
|
||||
if features == nil {
|
||||
features = &config.Features{}
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "http://cdn.example.com",
|
||||
PluginSettings: pluginSettings,
|
||||
Features: *features,
|
||||
}
|
||||
|
||||
svc := NewCalculator(
|
||||
pCfg,
|
||||
newPluginRegistry(t, tc.registry...),
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
)
|
||||
mh := svc.ModuleHash(context.Background(), tc.plugin, "")
|
||||
require.Equal(t, tc.expModuleHash, mh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ModuleHash_Cache(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginSettings: config.PluginSettings{},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc := NewCalculator(
|
||||
pCfg,
|
||||
newPluginRegistry(t),
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
)
|
||||
const pluginID = "grafana-test-datasource"
|
||||
|
||||
t.Run("cache key", func(t *testing.T) {
|
||||
t.Run("with version", func(t *testing.T) {
|
||||
const pluginVersion = "1.0.0"
|
||||
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
|
||||
k := svc.moduleHashCacheKey(p.ID, p.Info.Version)
|
||||
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
|
||||
})
|
||||
|
||||
t.Run("without version", func(t *testing.T) {
|
||||
p := newPlugin(pluginID)
|
||||
k := svc.moduleHashCacheKey(p.ID, p.Info.Version)
|
||||
require.Equal(t, pluginID+":", k, "cache key should be correct")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ModuleHash usage", func(t *testing.T) {
|
||||
pV1 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "1.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid"))),
|
||||
)
|
||||
|
||||
pCfg = &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.grafana.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
reg := newPluginRegistry(t, pV1)
|
||||
svc = NewCalculator(
|
||||
pCfg,
|
||||
reg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
)
|
||||
|
||||
k := svc.moduleHashCacheKey(pV1.ID, pV1.Info.Version)
|
||||
|
||||
_, ok := svc.moduleHashCache.Load(k)
|
||||
require.False(t, ok, "cache should initially be empty")
|
||||
|
||||
mhV1 := svc.ModuleHash(context.Background(), pV1.ID, pV1.Info.Version)
|
||||
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
|
||||
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
|
||||
|
||||
cachedMh, ok := svc.moduleHashCache.Load(k)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
|
||||
|
||||
t.Run("different version uses different cache key", func(t *testing.T) {
|
||||
pV2 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "2.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
// different fs for different hash
|
||||
withFS(plugins.NewLocalFS(filepath.Join("../testdata", "module-hash-valid-nested"))),
|
||||
)
|
||||
err := reg.Add(context.Background(), pV2)
|
||||
require.NoError(t, err)
|
||||
|
||||
mhV2 := svc.ModuleHash(context.Background(), pV2.ID, pV2.Info.Version)
|
||||
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
|
||||
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
|
||||
})
|
||||
|
||||
t.Run("cache should be used", func(t *testing.T) {
|
||||
// edit cache directly
|
||||
svc.moduleHashCache.Store(k, "hax")
|
||||
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1.ID, pV1.Info.Version))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertHashFromSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r, err := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, cbs ...func(p *plugins.Plugin) *plugins.Plugin) *plugins.Plugin {
|
||||
p := &plugins.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
ID: pluginID,
|
||||
},
|
||||
}
|
||||
for _, cb := range cbs {
|
||||
p = cb(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func withInfo(info plugins.Info) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Info = info
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withFS(fs plugins.FS) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.FS = fs
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withSignatureStatus(status plugins.SignatureStatus) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Signature = status
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withParent(parent *plugins.Plugin) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Parent = parent
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withClass(class plugins.Class) func(p *plugins.Plugin) *plugins.Plugin {
|
||||
return func(p *plugins.Plugin) *plugins.Plugin {
|
||||
p.Class = class
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func newSRIHash(t *testing.T, s string) string {
|
||||
r, err := convertHashForSRI(s)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
type pluginRegistry struct {
|
||||
registry.Service
|
||||
|
||||
reg map[string]*plugins.Plugin
|
||||
}
|
||||
|
||||
func newPluginRegistry(t *testing.T, ps ...*plugins.Plugin) *pluginRegistry {
|
||||
reg := &pluginRegistry{
|
||||
reg: make(map[string]*plugins.Plugin),
|
||||
}
|
||||
for _, p := range ps {
|
||||
err := reg.Add(context.Background(), p)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func (f *pluginRegistry) Plugin(_ context.Context, id, version string) (*plugins.Plugin, bool) {
|
||||
key := fmt.Sprintf("%s-%s", id, version)
|
||||
p, exists := f.reg[key]
|
||||
return p, exists
|
||||
}
|
||||
|
||||
func (f *pluginRegistry) Add(_ context.Context, p *plugins.Plugin) error {
|
||||
key := fmt.Sprintf("%s-%s", p.ID, p.Info.Version)
|
||||
f.reg[key] = p
|
||||
return nil
|
||||
}
|
||||
6
pkg/server/wire_gen.go
generated
6
pkg/server/wire_gen.go
generated
@@ -715,7 +715,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
return nil, err
|
||||
}
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
|
||||
calculator := pluginassets2.ProvideModuleHashCalculator(pluginManagementCfg, pluginscdnService, signatureSignature, inMemory)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, calculator)
|
||||
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
|
||||
prefService := prefimpl.ProvideService(sqlStore, cfg)
|
||||
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
|
||||
@@ -1383,7 +1384,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
return nil, err
|
||||
}
|
||||
pluginscdnService := pluginscdn.ProvideService(pluginManagementCfg)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, signatureSignature, pluginstoreService)
|
||||
calculator := pluginassets2.ProvideModuleHashCalculator(pluginManagementCfg, pluginscdnService, signatureSignature, inMemory)
|
||||
pluginassetsService := pluginassets2.ProvideService(pluginManagementCfg, pluginscdnService, calculator)
|
||||
avatarCacheServer := avatar.ProvideAvatarCacheServer(cfg)
|
||||
prefService := prefimpl.ProvideService(sqlStore, cfg)
|
||||
dashboardPermissionsService, err := ossaccesscontrol.ProvideDashboardPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, dashboardService, folderimplService, acimplService, teamService, userService, actionSetService, dashboardServiceImpl, eventualRestConfigProvider)
|
||||
|
||||
@@ -2,19 +2,15 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/registry"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginassets/modulehash"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -28,24 +24,27 @@ var (
|
||||
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
|
||||
)
|
||||
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service, sig *signature.Signature, store pluginstore.Store) *Service {
|
||||
func ProvideService(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service,
|
||||
calc *modulehash.Calculator) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
signature: sig,
|
||||
store: store,
|
||||
log: log.New("pluginassets"),
|
||||
cfg: cfg,
|
||||
cdn: cdn,
|
||||
log: log.New("pluginassets"),
|
||||
calc: calc,
|
||||
}
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
signature *signature.Signature
|
||||
store pluginstore.Store
|
||||
log log.Logger
|
||||
func ProvideModuleHashCalculator(cfg *config.PluginManagementCfg, cdn *pluginscdn.Service,
|
||||
signature *signature.Signature, reg registry.Service) *modulehash.Calculator {
|
||||
return modulehash.NewCalculator(cfg, reg, cdn, signature)
|
||||
}
|
||||
|
||||
moduleHashCache sync.Map
|
||||
type Service struct {
|
||||
cfg *config.PluginManagementCfg
|
||||
cdn *pluginscdn.Service
|
||||
calc *modulehash.Calculator
|
||||
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// LoadingStrategy calculates the loading strategy for a plugin.
|
||||
@@ -83,92 +82,8 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi
|
||||
}
|
||||
|
||||
// ModuleHash returns the module.js SHA256 hash for a plugin in the format expected by the browser for SRI checks.
|
||||
// The module hash is read from the plugin's MANIFEST.txt file.
|
||||
// The plugin can also be a nested plugin.
|
||||
// If the plugin is unsigned, an empty string is returned.
|
||||
// The results are cached to avoid repeated reads from the MANIFEST.txt file.
|
||||
func (s *Service) ModuleHash(ctx context.Context, p pluginstore.Plugin) string {
|
||||
k := s.moduleHashCacheKey(p)
|
||||
cachedValue, ok := s.moduleHashCache.Load(k)
|
||||
if ok {
|
||||
return cachedValue.(string)
|
||||
}
|
||||
mh, err := s.moduleHash(ctx, p, "")
|
||||
if err != nil {
|
||||
s.log.Error("Failed to calculate module hash", "plugin", p.ID, "error", err)
|
||||
}
|
||||
s.moduleHashCache.Store(k, mh)
|
||||
return mh
|
||||
}
|
||||
|
||||
// moduleHash is the underlying function for ModuleHash. See its documentation for more information.
|
||||
// If the plugin is not a CDN plugin, the function will return an empty string.
|
||||
// It will read the module hash from the MANIFEST.txt in the [[plugins.FS]] of the provided plugin.
|
||||
// If childFSBase is provided, the function will try to get the hash from MANIFEST.txt for the provided children's
|
||||
// module.js file, rather than for the provided plugin.
|
||||
func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSBase string) (r string, err error) {
|
||||
if !s.cfg.Features.SriChecksEnabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ignore unsigned plugins
|
||||
if !p.Signature.IsValid() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if p.Parent != nil {
|
||||
// Nested plugin
|
||||
parent, ok := s.store.Plugin(ctx, p.Parent.ID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("parent plugin plugin %q for child plugin %q not found", p.Parent.ID, p.ID)
|
||||
}
|
||||
|
||||
// The module hash is contained within the parent's MANIFEST.txt file.
|
||||
// For example, the parent's MANIFEST.txt will contain an entry similar to this:
|
||||
//
|
||||
// ```
|
||||
// "datasource/module.js": "1234567890abcdef..."
|
||||
// ```
|
||||
//
|
||||
// Recursively call moduleHash with the parent plugin and with the children plugin folder path
|
||||
// to get the correct module hash for the nested plugin.
|
||||
if childFSBase == "" {
|
||||
childFSBase = p.Base()
|
||||
}
|
||||
return s.moduleHash(ctx, parent, childFSBase)
|
||||
}
|
||||
|
||||
// Only CDN plugins are supported for SRI checks.
|
||||
// CDN plugins have the version as part of the URL, which acts as a cache-buster.
|
||||
// Needed due to: https://github.com/grafana/plugin-tools/pull/1426
|
||||
// FS plugins build before this change will have SRI mismatch issues.
|
||||
if !s.cdnEnabled(p.ID, p.FS) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
manifest, err := s.signature.ReadPluginManifestFromFS(ctx, p.FS)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if !manifest.IsV2() {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var childPath string
|
||||
if childFSBase != "" {
|
||||
// Calculate the relative path of the child plugin folder from the parent plugin folder.
|
||||
childPath, err = p.FS.Rel(childFSBase)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("rel path: %w", err)
|
||||
}
|
||||
// MANIFETS.txt uses forward slashes as path separators.
|
||||
childPath = filepath.ToSlash(childPath)
|
||||
}
|
||||
moduleHash, ok := manifest.Files[path.Join(childPath, "module.js")]
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
return convertHashForSRI(moduleHash)
|
||||
return s.calc.ModuleHash(ctx, p.ID, p.Info.Version)
|
||||
}
|
||||
|
||||
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
@@ -188,17 +103,3 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
|
||||
func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool {
|
||||
return s.cdn.PluginSupported(pluginID) || fs.Type().CDN()
|
||||
}
|
||||
|
||||
// convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks.
|
||||
func convertHashForSRI(h string) (string, error) {
|
||||
hb, err := hex.DecodeString(h)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hex decode string: %w", err)
|
||||
}
|
||||
return "sha256-" + base64.StdEncoding.EncodeToString(hb), nil
|
||||
}
|
||||
|
||||
// moduleHashCacheKey returns a unique key for the module hash cache.
|
||||
func (s *Service) moduleHashCacheKey(p pluginstore.Plugin) string {
|
||||
return p.ID + ":" + p.Info.Version
|
||||
}
|
||||
|
||||
@@ -2,19 +2,14 @@ package pluginassets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/pluginfakes"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||
)
|
||||
@@ -179,349 +174,6 @@ func TestService_Calculate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash(t *testing.T) {
|
||||
const (
|
||||
pluginID = "grafana-test-datasource"
|
||||
parentPluginID = "grafana-test-app"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
features *config.Features
|
||||
store []pluginstore.Plugin
|
||||
|
||||
// Can be used to configure plugin's fs
|
||||
// fs cdn type = loaded from CDN with no files on disk
|
||||
// fs local type = files on disk but served from CDN only if cdn=true
|
||||
plugin pluginstore.Plugin
|
||||
|
||||
// When true, set cdn=true in config
|
||||
cdn bool
|
||||
expModuleHash string
|
||||
}{
|
||||
{
|
||||
name: "unsigned should not return module hash",
|
||||
plugin: newPlugin(pluginID, withSignatureStatus(plugins.SignatureStatusUnsigned)),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
withClass(plugins.ClassExternal),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"),
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: false},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/datasource)
|
||||
name: "nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "04d70db091d96c4775fb32ba5a8f84cc22893eb43afdb649726661d4425c6711"),
|
||||
},
|
||||
{
|
||||
// parentPluginID (/)
|
||||
// └── pluginID (/panels/one)
|
||||
name: "nested plugin deeper than one subfolder should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
parentPluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
// grand-parent-app (/)
|
||||
// ├── parent-datasource (/datasource)
|
||||
// │ └── child-panel (/datasource/panels/one)
|
||||
name: "nested plugin of a nested plugin should return module hash from parent MANIFEST.txt",
|
||||
store: []pluginstore.Plugin{
|
||||
newPlugin(
|
||||
"grand-parent-app",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))),
|
||||
),
|
||||
newPlugin(
|
||||
"parent-datasource",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))),
|
||||
withParent("grand-parent-app"),
|
||||
),
|
||||
},
|
||||
plugin: newPlugin(
|
||||
"child-panel",
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))),
|
||||
withParent("parent-datasource"),
|
||||
),
|
||||
cdn: true,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: newSRIHash(t, "cbd1ac2284645a0e1e9a8722a729f5bcdd2b831222728709c6360beecdd6143f"),
|
||||
},
|
||||
{
|
||||
name: "nested plugin should not return module hash from parent if it's not registered in the store",
|
||||
store: []pluginstore.Plugin{},
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))),
|
||||
withParent(parentPluginID),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "missing module.js entry from MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-module-js"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
{
|
||||
name: "signed status but missing MANIFEST.txt should not return module hash",
|
||||
plugin: newPlugin(
|
||||
pluginID,
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-no-manifest-txt"))),
|
||||
),
|
||||
cdn: false,
|
||||
features: &config.Features{SriChecksEnabled: true},
|
||||
expModuleHash: "",
|
||||
},
|
||||
} {
|
||||
if tc.name == "" {
|
||||
var expS string
|
||||
if tc.expModuleHash == "" {
|
||||
expS = "should not return module hash"
|
||||
} else {
|
||||
expS = "should return module hash"
|
||||
}
|
||||
tc.name = fmt.Sprintf("feature=%v, cdn_config=%v, class=%v %s", tc.features.SriChecksEnabled, tc.cdn, tc.plugin.Class, expS)
|
||||
}
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var pluginSettings config.PluginSettings
|
||||
if tc.cdn {
|
||||
pluginSettings = config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
parentPluginID: map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
"grand-parent-app": map[string]string{
|
||||
"cdn": "true",
|
||||
},
|
||||
}
|
||||
}
|
||||
features := tc.features
|
||||
if features == nil {
|
||||
features = &config.Features{}
|
||||
}
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "http://cdn.example.com",
|
||||
PluginSettings: pluginSettings,
|
||||
Features: *features,
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(tc.store...),
|
||||
)
|
||||
mh := svc.ModuleHash(context.Background(), tc.plugin)
|
||||
require.Equal(t, tc.expModuleHash, mh)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ModuleHash_Cache(t *testing.T) {
|
||||
pCfg := &config.PluginManagementCfg{
|
||||
PluginSettings: config.PluginSettings{},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc := ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
const pluginID = "grafana-test-datasource"
|
||||
|
||||
t.Run("cache key", func(t *testing.T) {
|
||||
t.Run("with version", func(t *testing.T) {
|
||||
const pluginVersion = "1.0.0"
|
||||
p := newPlugin(pluginID, withInfo(plugins.Info{Version: pluginVersion}))
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":"+pluginVersion, k, "cache key should be correct")
|
||||
})
|
||||
|
||||
t.Run("without version", func(t *testing.T) {
|
||||
p := newPlugin(pluginID)
|
||||
k := svc.moduleHashCacheKey(p)
|
||||
require.Equal(t, pluginID+":", k, "cache key should be correct")
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("ModuleHash usage", func(t *testing.T) {
|
||||
pV1 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "1.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))),
|
||||
)
|
||||
|
||||
pCfg = &config.PluginManagementCfg{
|
||||
PluginsCDNURLTemplate: "https://cdn.grafana.com",
|
||||
PluginSettings: config.PluginSettings{
|
||||
pluginID: {
|
||||
"cdn": "true",
|
||||
},
|
||||
},
|
||||
Features: config.Features{SriChecksEnabled: true},
|
||||
}
|
||||
svc = ProvideService(
|
||||
pCfg,
|
||||
pluginscdn.ProvideService(pCfg),
|
||||
signature.ProvideService(pCfg, statickey.New()),
|
||||
pluginstore.NewFakePluginStore(),
|
||||
)
|
||||
|
||||
k := svc.moduleHashCacheKey(pV1)
|
||||
|
||||
_, ok := svc.moduleHashCache.Load(k)
|
||||
require.False(t, ok, "cache should initially be empty")
|
||||
|
||||
mhV1 := svc.ModuleHash(context.Background(), pV1)
|
||||
pV1Exp := newSRIHash(t, "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
|
||||
require.Equal(t, pV1Exp, mhV1, "returned value should be correct")
|
||||
|
||||
cachedMh, ok := svc.moduleHashCache.Load(k)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, pV1Exp, cachedMh, "cache should contain the returned value")
|
||||
|
||||
t.Run("different version uses different cache key", func(t *testing.T) {
|
||||
pV2 := newPlugin(
|
||||
pluginID,
|
||||
withInfo(plugins.Info{Version: "2.0.0"}),
|
||||
withSignatureStatus(plugins.SignatureStatusValid),
|
||||
// different fs for different hash
|
||||
withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))),
|
||||
)
|
||||
mhV2 := svc.ModuleHash(context.Background(), pV2)
|
||||
require.NotEqual(t, mhV2, mhV1, "different version should have different hash")
|
||||
require.Equal(t, newSRIHash(t, "266c19bc148b22ddef2a288fc5f8f40855bda22ccf60be53340b4931e469ae2a"), mhV2)
|
||||
})
|
||||
|
||||
t.Run("cache should be used", func(t *testing.T) {
|
||||
// edit cache directly
|
||||
svc.moduleHashCache.Store(k, "hax")
|
||||
require.Equal(t, "hax", svc.ModuleHash(context.Background(), pV1))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertHashFromSRI(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
hash string
|
||||
expHash string
|
||||
expErr bool
|
||||
}{
|
||||
{
|
||||
hash: "ddfcb449445064e6c39f0c20b15be3cb6a55837cf4781df23d02de005f436811",
|
||||
expHash: "sha256-3fy0SURQZObDnwwgsVvjy2pVg3z0eB3yPQLeAF9DaBE=",
|
||||
},
|
||||
{
|
||||
hash: "not-a-valid-hash",
|
||||
expErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.hash, func(t *testing.T) {
|
||||
r, err := convertHashForSRI(tc.hash)
|
||||
if tc.expErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expHash, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
|
||||
p := pluginstore.Plugin{
|
||||
JSONData: plugins.JSONData{
|
||||
@@ -534,13 +186,6 @@ func newPlugin(pluginID string, cbs ...func(p pluginstore.Plugin) pluginstore.Pl
|
||||
return p
|
||||
}
|
||||
|
||||
func withInfo(info plugins.Info) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Info = info
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.FS = fs
|
||||
@@ -548,13 +193,6 @@ func withFS(fs plugins.FS) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
func withSignatureStatus(status plugins.SignatureStatus) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Signature = status
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Angular = plugins.AngularMeta{Detected: angular}
|
||||
@@ -562,13 +200,6 @@ func withAngular(angular bool) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
func withParent(parentID string) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Parent = &pluginstore.ParentPlugin{ID: parentID}
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
func withClass(class plugins.Class) func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
return func(p pluginstore.Plugin) pluginstore.Plugin {
|
||||
p.Class = class
|
||||
@@ -587,9 +218,3 @@ func newPluginSettings(pluginID string, kv map[string]string) config.PluginSetti
|
||||
pluginID: kv,
|
||||
}
|
||||
}
|
||||
|
||||
func newSRIHash(t *testing.T, s string) string {
|
||||
r, err := convertHashForSRI(s)
|
||||
require.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ var WireSet = wire.NewSet(
|
||||
plugincontext.ProvideBaseService,
|
||||
wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)),
|
||||
plugininstaller.ProvideService,
|
||||
pluginassets.ProvideModuleHashCalculator,
|
||||
pluginassets.ProvideService,
|
||||
pluginchecker.ProvidePreinstall,
|
||||
wire.Bind(new(pluginchecker.Preinstall), new(*pluginchecker.PreinstallImpl)),
|
||||
|
||||
@@ -56,24 +56,6 @@ export const ImportedContactPointAlert = (props: ExtraAlertProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ImportedTimeIntervalAlert = (props: ExtraAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
title={t(
|
||||
'alerting.provisioning.title-imported-time-interval',
|
||||
'This time interval was imported and cannot be edited through the UI'
|
||||
)}
|
||||
severity="info"
|
||||
{...props}
|
||||
>
|
||||
<Trans i18nKey="alerting.provisioning.body-imported-time-interval">
|
||||
This time interval was imported from an external Alertmanager and is currently read-only. The time interval will
|
||||
become editable after the migration process is complete.
|
||||
</Trans>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProvisioningBadge = ({
|
||||
tooltip,
|
||||
provenance,
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { render, screen, userEvent } from 'test/test-utils';
|
||||
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
|
||||
import { setTimeIntervalsList } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import MuteTimingsSelector from './MuteTimingsSelector';
|
||||
|
||||
const renderWithProvider = (alertManagerSource = GRAFANA_RULES_SOURCE_NAME) => {
|
||||
return render(
|
||||
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertManagerSource}>
|
||||
<MuteTimingsSelector
|
||||
alertmanager={alertManagerSource}
|
||||
selectProps={{
|
||||
onChange: () => {},
|
||||
}}
|
||||
/>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
setupMswServer();
|
||||
|
||||
describe('MuteTimingsSelector', () => {
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should show all non-imported time intervals', async () => {
|
||||
const user = userEvent.setup();
|
||||
setTimeIntervalsList([
|
||||
{ name: 'regular-interval', provenance: 'none' },
|
||||
{ name: 'file-provisioned', provenance: 'file' },
|
||||
{ name: 'another-regular', provenance: 'none' },
|
||||
]);
|
||||
|
||||
renderWithProvider();
|
||||
|
||||
// Click to open the dropdown
|
||||
const selector = await screen.findByRole('combobox', { name: /time intervals/i });
|
||||
await user.click(selector);
|
||||
|
||||
// All non-imported intervals should be visible
|
||||
expect(await screen.findByText('regular-interval')).toBeInTheDocument();
|
||||
expect(screen.getByText('file-provisioned')).toBeInTheDocument();
|
||||
expect(screen.getByText('another-regular')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should filter out imported time intervals (provenance: converted_prometheus)', async () => {
|
||||
const user = userEvent.setup();
|
||||
setTimeIntervalsList([
|
||||
{ name: 'regular-interval', provenance: 'none' },
|
||||
{ name: 'imported-interval', provenance: 'converted_prometheus' },
|
||||
{ name: 'file-provisioned', provenance: 'file' },
|
||||
]);
|
||||
|
||||
renderWithProvider();
|
||||
|
||||
// Click to open the dropdown
|
||||
const selector = await screen.findByRole('combobox', { name: /time intervals/i });
|
||||
await user.click(selector);
|
||||
|
||||
// Regular and file-provisioned should be visible
|
||||
expect(await screen.findByText('regular-interval')).toBeInTheDocument();
|
||||
expect(screen.getByText('file-provisioned')).toBeInTheDocument();
|
||||
|
||||
// Imported interval should NOT be in the list
|
||||
expect(screen.queryByText('imported-interval')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show only non-imported intervals when all types are present', async () => {
|
||||
const user = userEvent.setup();
|
||||
setTimeIntervalsList([
|
||||
{ name: 'normal-1', provenance: 'none' },
|
||||
{ name: 'imported-1', provenance: 'converted_prometheus' },
|
||||
{ name: 'normal-2', provenance: 'none' },
|
||||
{ name: 'imported-2', provenance: 'converted_prometheus' },
|
||||
{ name: 'file-1', provenance: 'file' },
|
||||
]);
|
||||
|
||||
renderWithProvider();
|
||||
|
||||
// Click to open the dropdown
|
||||
const selector = await screen.findByRole('combobox', { name: /time intervals/i });
|
||||
await user.click(selector);
|
||||
|
||||
// Non-imported intervals should be visible
|
||||
expect(await screen.findByText('normal-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('normal-2')).toBeInTheDocument();
|
||||
expect(screen.getByText('file-1')).toBeInTheDocument();
|
||||
|
||||
// Imported intervals should NOT be visible
|
||||
expect(screen.queryByText('imported-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('imported-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty list', async () => {
|
||||
setTimeIntervalsList([]);
|
||||
|
||||
renderWithProvider();
|
||||
|
||||
// Selector should be present but have no options
|
||||
const selector = await screen.findByRole('combobox', { name: /time intervals/i });
|
||||
expect(selector).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle list with only imported intervals', async () => {
|
||||
const user = userEvent.setup();
|
||||
setTimeIntervalsList([
|
||||
{ name: 'imported-1', provenance: 'converted_prometheus' },
|
||||
{ name: 'imported-2', provenance: 'converted_prometheus' },
|
||||
]);
|
||||
|
||||
renderWithProvider();
|
||||
|
||||
// Click to open the dropdown
|
||||
const selector = await screen.findByRole('combobox', { name: /time intervals/i });
|
||||
await user.click(selector);
|
||||
|
||||
// No intervals should be visible
|
||||
expect(screen.queryByText('imported-1')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('imported-2')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,17 @@
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { MultiSelect, MultiSelectCommonProps } from '@grafana/ui';
|
||||
import { MuteTiming, useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
|
||||
import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
|
||||
import { BaseAlertmanagerArgs } from 'app/features/alerting/unified/types/hooks';
|
||||
import { timeIntervalToString } from 'app/features/alerting/unified/utils/alertmanager';
|
||||
import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
import { isImportedResource } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
|
||||
|
||||
const mapTimeInterval = ({ name, time_intervals }: MuteTiming): SelectableValue<string> => ({
|
||||
const mapTimeInterval = ({ name, time_intervals }: MuteTimeInterval): SelectableValue<string> => ({
|
||||
value: name,
|
||||
label: name,
|
||||
description: time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '),
|
||||
});
|
||||
|
||||
/** Check if a time interval was imported from an external Alertmanager */
|
||||
const isImportedTimeInterval = (timing: MuteTiming): boolean => {
|
||||
const provenance = timing.metadata?.annotations?.[K8sAnnotations.Provenance];
|
||||
return isImportedResource(provenance);
|
||||
};
|
||||
|
||||
/** Provides a MultiSelect with available time intervals for the given alertmanager */
|
||||
const TimeIntervalSelector = ({
|
||||
alertmanager,
|
||||
@@ -26,9 +19,7 @@ const TimeIntervalSelector = ({
|
||||
}: BaseAlertmanagerArgs & { selectProps: MultiSelectCommonProps<string> }) => {
|
||||
const { data } = useMuteTimings({ alertmanager, skip: selectProps.disabled });
|
||||
|
||||
// Filter out imported time intervals (provenance === 'prometheus_convert')
|
||||
const availableTimings = data?.filter((timing) => !isImportedTimeInterval(timing)) || [];
|
||||
const timeIntervalOptions = availableTimings.map((value) => mapTimeInterval(value));
|
||||
const timeIntervalOptions = data?.map((value) => mapTimeInterval(value)) || [];
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom-v5-compat';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { useGetMuteTiming } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
|
||||
import { useURLSearchParams } from 'app/features/alerting/unified/hooks/useURLSearchParams';
|
||||
import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
|
||||
|
||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||
import { withPageErrorBoundary } from '../../withPageErrorBoundary';
|
||||
@@ -29,15 +28,13 @@ const EditTimingRoute = () => {
|
||||
return <Navigate replace to="/alerting/routes" />;
|
||||
}
|
||||
|
||||
const provenance = timeInterval?.metadata?.annotations?.[K8sAnnotations.Provenance];
|
||||
|
||||
return (
|
||||
<MuteTimingForm
|
||||
editMode
|
||||
loading={isLoading}
|
||||
showError={isError}
|
||||
muteTiming={timeInterval}
|
||||
provenance={provenance}
|
||||
provisioned={timeInterval?.provisioned}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { render, screen } from 'test/test-utils';
|
||||
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
|
||||
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
|
||||
import MuteTimingForm from './MuteTimingForm';
|
||||
import { muteTimeInterval } from './mocks';
|
||||
|
||||
const renderWithProvider = (provenance?: string, editMode = false) => {
|
||||
return render(
|
||||
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={GRAFANA_RULES_SOURCE_NAME}>
|
||||
<MuteTimingForm
|
||||
muteTiming={{ id: 'mock-id', ...muteTimeInterval }}
|
||||
provenance={provenance}
|
||||
editMode={editMode}
|
||||
loading={false}
|
||||
showError={false}
|
||||
/>
|
||||
</AlertmanagerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
setupMswServer();
|
||||
|
||||
describe('MuteTimingForm', () => {
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not show any alert when provenance is none', async () => {
|
||||
renderWithProvider('none');
|
||||
|
||||
expect(screen.queryByText(/imported and cannot be edited/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/provisioned/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show any alert when provenance is undefined', async () => {
|
||||
renderWithProvider(undefined);
|
||||
|
||||
expect(screen.queryByText(/imported and cannot be edited/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/provisioned/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show imported alert when provenance is converted_prometheus', async () => {
|
||||
renderWithProvider('converted_prometheus');
|
||||
|
||||
expect(
|
||||
await screen.findByText(/This time interval was imported and cannot be edited through the UI/i)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/This time interval was imported from an external Alertmanager and is currently read-only/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show provisioning alert when provenance is file', async () => {
|
||||
renderWithProvider('file');
|
||||
|
||||
expect(await screen.findByText(/This time interval cannot be edited through the UI/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/This time interval has been provisioned, that means it was created by config/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show provisioning alert for other provenance types', async () => {
|
||||
renderWithProvider('api');
|
||||
|
||||
expect(await screen.findByText(/This time interval cannot be edited through the UI/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable form when provenance is converted_prometheus', async () => {
|
||||
renderWithProvider('converted_prometheus', true);
|
||||
|
||||
const nameInput = await screen.findByTestId('mute-timing-name');
|
||||
expect(nameInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable form when provenance is file', async () => {
|
||||
renderWithProvider('file', true);
|
||||
|
||||
const nameInput = await screen.findByTestId('mute-timing-name');
|
||||
expect(nameInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should enable form when provenance is none', async () => {
|
||||
renderWithProvider('none', true);
|
||||
|
||||
const nameInput = await screen.findByTestId('mute-timing-name');
|
||||
expect(nameInput).toBeEnabled();
|
||||
});
|
||||
|
||||
it('should enable form when provenance is undefined', async () => {
|
||||
renderWithProvider(undefined, true);
|
||||
|
||||
const nameInput = await screen.findByTestId('mute-timing-name');
|
||||
expect(nameInput).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
|
||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||
import { MuteTimingFields } from '../../types/mute-timing-form';
|
||||
import { isImportedResource, isProvisionedResource } from '../../utils/k8s/utils';
|
||||
import { makeAMLink } from '../../utils/misc';
|
||||
import { createMuteTiming, defaultTimeInterval, isTimeIntervalDisabled } from '../../utils/mute-timings';
|
||||
import { ImportedTimeIntervalAlert, ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||||
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
|
||||
|
||||
import { MuteTimingTimeInterval } from './MuteTimingTimeInterval';
|
||||
|
||||
@@ -25,8 +24,8 @@ interface Props {
|
||||
muteTiming?: MuteTiming;
|
||||
showError?: boolean;
|
||||
loading?: boolean;
|
||||
/** Provenance of the mute timing - indicates how it was created (e.g., 'file', 'prometheus_convert', 'none') */
|
||||
provenance?: string;
|
||||
/** Is the current mute timing provisioned? If so, will disable editing via UI */
|
||||
provisioned?: boolean;
|
||||
/** Are we editing an existing time interval? */
|
||||
editMode?: boolean;
|
||||
}
|
||||
@@ -57,7 +56,7 @@ const useDefaultValues = (muteTiming?: MuteTiming): MuteTimingFields => {
|
||||
};
|
||||
};
|
||||
|
||||
const MuteTimingForm = ({ muteTiming, showError, loading, provenance, editMode }: Props) => {
|
||||
const MuteTimingForm = ({ muteTiming, showError, loading, provisioned, editMode }: Props) => {
|
||||
const { selectedAlertmanager } = useAlertmanager();
|
||||
const hookArgs = { alertmanager: selectedAlertmanager! };
|
||||
|
||||
@@ -106,19 +105,14 @@ const MuteTimingForm = ({ muteTiming, showError, loading, provenance, editMode }
|
||||
);
|
||||
}
|
||||
|
||||
const isProvisioned = isProvisionedResource(provenance);
|
||||
const isImported = isImportedResource(provenance);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isProvisioned && isImported && <ImportedTimeIntervalAlert />}
|
||||
{isProvisioned && !isImported && <ProvisioningAlert resource={ProvisionedResource.MuteTiming} />}
|
||||
{provisioned && <ProvisioningAlert resource={ProvisionedResource.MuteTiming} />}
|
||||
<FormProvider {...formApi}>
|
||||
<form onSubmit={formApi.handleSubmit(onSubmit)} data-testid="mute-timing-form">
|
||||
<FieldSet disabled={isProvisioned || updating}>
|
||||
<FieldSet disabled={provisioned || updating}>
|
||||
<Field
|
||||
required
|
||||
noMargin
|
||||
label={t('alerting.mute-timing-form.label-name', 'Name')}
|
||||
description={t(
|
||||
'alerting.time-interval-form.description-unique-time-interval',
|
||||
|
||||
@@ -27,7 +27,6 @@ export function MuteTimingFields({ alertmanager }: BaseAlertmanagerArgs) {
|
||||
)}
|
||||
className={styles.muteTimingField}
|
||||
invalid={!!errors.contactPoints?.[alertmanager]?.muteTimeIntervals}
|
||||
noMargin
|
||||
>
|
||||
<Controller
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
|
||||
@@ -175,36 +175,6 @@ export const setTimeIntervalsListEmpty = () => {
|
||||
return handler;
|
||||
};
|
||||
|
||||
interface TimeIntervalConfig {
|
||||
name: string;
|
||||
provenance?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the mock server respond with custom time intervals
|
||||
*/
|
||||
export const setTimeIntervalsList = (intervals: TimeIntervalConfig[]) => {
|
||||
const listMuteTimingsPath = listNamespacedTimeIntervalHandler().info.path;
|
||||
const handler = http.get(listMuteTimingsPath, () => {
|
||||
const items = intervals.map((interval) => ({
|
||||
metadata: {
|
||||
annotations: {
|
||||
'grafana.com/provenance': interval.provenance ?? 'none',
|
||||
},
|
||||
name: interval.name,
|
||||
uid: `uid-${interval.name}`,
|
||||
namespace: 'default',
|
||||
resourceVersion: 'e0270bfced786660',
|
||||
},
|
||||
spec: { name: interval.name, time_intervals: [] },
|
||||
}));
|
||||
return HttpResponse.json(getK8sResponse('TimeIntervalList', items));
|
||||
});
|
||||
|
||||
server.use(handler);
|
||||
return handler;
|
||||
};
|
||||
|
||||
export function mimirDataSource() {
|
||||
const dataSource = mockDataSource(
|
||||
{
|
||||
|
||||
@@ -65,7 +65,3 @@ export const stringifyFieldSelector = (fieldSelectors: FieldSelector[]): string
|
||||
export function isProvisionedResource(provenance?: string): boolean {
|
||||
return Boolean(provenance && provenance !== KnownProvenance.None);
|
||||
}
|
||||
|
||||
export function isImportedResource(provenance?: string): boolean {
|
||||
return provenance === KnownProvenance.ConvertedPrometheus;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { render, screen } from '@testing-library/react';
|
||||
import { defaultsDeep } from 'lodash';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { CoreApp, EventBusSrv, FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data';
|
||||
import { config, PanelDataErrorViewProps } from '@grafana/runtime';
|
||||
import { usePanelContext } from '@grafana/ui';
|
||||
import { FieldType, getDefaultTimeRange, LoadingState } from '@grafana/data';
|
||||
import { PanelDataErrorViewProps } from '@grafana/runtime';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { PanelDataErrorView } from './PanelDataErrorView';
|
||||
@@ -17,24 +16,7 @@ jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@grafana/ui', () => ({
|
||||
...jest.requireActual('@grafana/ui'),
|
||||
usePanelContext: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUsePanelContext = jest.mocked(usePanelContext);
|
||||
const RUN_QUERY_MESSAGE = 'Run a query to visualize it here or go to all visualizations to add other panel types';
|
||||
const panelContextRoot = {
|
||||
app: CoreApp.Dashboard,
|
||||
eventsScope: 'global',
|
||||
eventBus: new EventBusSrv(),
|
||||
};
|
||||
|
||||
describe('PanelDataErrorView', () => {
|
||||
beforeEach(() => {
|
||||
mockUsePanelContext.mockReturnValue(panelContextRoot);
|
||||
});
|
||||
|
||||
it('show No data when there is no data', () => {
|
||||
renderWithProps();
|
||||
|
||||
@@ -88,45 +70,6 @@ describe('PanelDataErrorView', () => {
|
||||
|
||||
expect(screen.getByText('Query returned nothing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Run a query..." message when no query is configured and feature toggle is enabled', () => {
|
||||
mockUsePanelContext.mockReturnValue(panelContextRoot);
|
||||
|
||||
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
|
||||
config.featureToggles.newVizSuggestions = true;
|
||||
|
||||
renderWithProps({
|
||||
data: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText(RUN_QUERY_MESSAGE)).toBeInTheDocument();
|
||||
|
||||
config.featureToggles.newVizSuggestions = originalFeatureToggle;
|
||||
});
|
||||
|
||||
it('should show "No data" message when feature toggle is disabled even without queries', () => {
|
||||
mockUsePanelContext.mockReturnValue(panelContextRoot);
|
||||
|
||||
const originalFeatureToggle = config.featureToggles.newVizSuggestions;
|
||||
config.featureToggles.newVizSuggestions = false;
|
||||
|
||||
renderWithProps({
|
||||
data: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
timeRange: getDefaultTimeRange(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
expect(screen.queryByText(RUN_QUERY_MESSAGE)).not.toBeInTheDocument();
|
||||
|
||||
config.featureToggles.newVizSuggestions = originalFeatureToggle;
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithProps(overrides?: Partial<PanelDataErrorViewProps>) {
|
||||
|
||||
@@ -5,15 +5,14 @@ import {
|
||||
FieldType,
|
||||
getPanelDataSummary,
|
||||
GrafanaTheme2,
|
||||
PanelData,
|
||||
PanelDataSummary,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { PanelDataErrorViewProps, locationService, config } from '@grafana/runtime';
|
||||
import { PanelDataErrorViewProps, locationService } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Icon, usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { usePanelContext, useStyles2 } from '@grafana/ui';
|
||||
import { CardButton } from 'app/core/components/CardButton';
|
||||
import { LS_VISUALIZATION_SELECT_TAB_KEY } from 'app/core/constants';
|
||||
import store from 'app/core/store';
|
||||
@@ -25,11 +24,6 @@ import { findVizPanelByKey, getVizPanelKeyForPanelId } from 'app/features/dashbo
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { changePanelPlugin } from '../state/actions';
|
||||
import { hasData } from '../suggestions/utils';
|
||||
|
||||
function hasNoQueryConfigured(data: PanelData): boolean {
|
||||
return !data.request?.targets || data.request.targets.length === 0;
|
||||
}
|
||||
|
||||
export function PanelDataErrorView(props: PanelDataErrorViewProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -99,14 +93,8 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const noData = !hasData(props.data);
|
||||
const noQueryConfigured = hasNoQueryConfigured(props.data);
|
||||
const showEmptyState =
|
||||
config.featureToggles.newVizSuggestions && context.app === CoreApp.PanelEditor && noQueryConfigured && noData;
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{showEmptyState && <Icon name="chart-line" size="xxxl" className={styles.emptyStateIcon} />}
|
||||
<div className={styles.message} data-testid={selectors.components.Panels.Panel.PanelDataErrorMessage}>
|
||||
{message}
|
||||
</div>
|
||||
@@ -143,17 +131,7 @@ function getMessageFor(
|
||||
return message;
|
||||
}
|
||||
|
||||
const noData = !hasData(data);
|
||||
const noQueryConfigured = hasNoQueryConfigured(data);
|
||||
|
||||
if (config.featureToggles.newVizSuggestions && noQueryConfigured && noData) {
|
||||
return t(
|
||||
'dashboard.new-panel.empty-state-message',
|
||||
'Run a query to visualize it here or go to all visualizations to add other panel types'
|
||||
);
|
||||
}
|
||||
|
||||
if (noData) {
|
||||
if (!data.series || data.series.length === 0 || data.series.every((frame) => frame.length === 0)) {
|
||||
return fieldConfig?.defaults.noValue ?? t('panel.panel-data-error-view.no-value.default', 'No data');
|
||||
}
|
||||
|
||||
@@ -198,9 +176,5 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
}),
|
||||
emptyStateIcon: css({
|
||||
color: theme.colors.text.secondary,
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2178,10 +2178,8 @@
|
||||
"badge-tooltip-provenance": "This resource has been provisioned via {{provenance}} and cannot be edited through the UI",
|
||||
"badge-tooltip-standard": "This resource has been provisioned and cannot be edited through the UI",
|
||||
"body-imported": "This contact point contains integrations that were imported from an external Alertmanager and is currently read-only. The integrations will become editable after the migration process is complete.",
|
||||
"body-imported-time-interval": "This time interval was imported from an external Alertmanager and is currently read-only. The time interval will become editable after the migration process is complete.",
|
||||
"body-provisioned": "This {{resource}} has been provisioned, that means it was created by config. Please contact your server admin to update this {{resource}}.",
|
||||
"title-imported": "This contact point was imported and cannot be edited through the UI",
|
||||
"title-imported-time-interval": "This time interval was imported and cannot be edited through the UI",
|
||||
"title-provisioned": "This {{resource}} cannot be edited through the UI"
|
||||
},
|
||||
"provisioning-badge": {
|
||||
|
||||
Reference in New Issue
Block a user