diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md
index 1bfb08672d5..4b75b9891e2 100644
--- a/docs/sources/setup-grafana/configure-grafana/_index.md
+++ b/docs/sources/setup-grafana/configure-grafana/_index.md
@@ -2195,6 +2195,12 @@ Available in Grafana v9.5.0 or later, and [OpenTelemetry must be configured as w
If `true`, propagate the tracing context to the plugin backend and enable tracing (if the backend supports it).
+## as_external
+
+Load an external version of a core plugin if it has been installed.
+
+Experimental. Requires the feature toggle `externalCorePlugins` to be enabled.
+
## [plugin.grafana-image-renderer]
diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
index 96fb0128294..f98d9a05e76 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -136,6 +136,7 @@ Experimental features might be changed or removed without prior notice.
| `requestInstrumentationStatusSource` | Include a status source label for request metrics and logs |
| `wargamesTesting` | Placeholder feature flag for internal testing |
| `alertingInsights` | Show the new alerting insights landing page |
+| `externalCorePlugins` | Allow core plugins to be loaded as external |
| `pluginsAPIMetrics` | Sends metrics of public grafana packages usage by plugins |
## Development feature toggles
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index ea13fc0f721..d9aaca3f322 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -127,5 +127,6 @@ export interface FeatureToggles {
lokiRunQueriesInParallel?: boolean;
wargamesTesting?: boolean;
alertingInsights?: boolean;
+ externalCorePlugins?: boolean;
pluginsAPIMetrics?: boolean;
}
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index caa4724d8b3..f28c6bf5c7c 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -759,6 +759,12 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
},
+ {
+ Name: "externalCorePlugins",
+ Description: "Allow core plugins to be loaded as external",
+ Stage: FeatureStageExperimental,
+ Owner: grafanaPluginsPlatformSquad,
+ },
{
Name: "pluginsAPIMetrics",
Description: "Sends metrics of public grafana packages usage by plugins",
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index b50154601ac..e98f7f74cb1 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -108,4 +108,5 @@ requestInstrumentationStatusSource,experimental,@grafana/plugins-platform-backen
lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false,false
wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false,false
alertingInsights,experimental,@grafana/alerting-squad,false,false,false,true
+externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false,false
pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index e04185c4814..3f6d18f77c1 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -443,6 +443,10 @@ const (
// Show the new alerting insights landing page
FlagAlertingInsights = "alertingInsights"
+ // FlagExternalCorePlugins
+ // Allow core plugins to be loaded as external
+ FlagExternalCorePlugins = "externalCorePlugins"
+
// FlagPluginsAPIMetrics
// Sends metrics of public grafana packages usage by plugins
FlagPluginsAPIMetrics = "pluginsAPIMetrics"
diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go
index 2656bf5936c..5d1e0a02b1b 100644
--- a/pkg/services/pluginsintegration/pipeline/pipeline.go
+++ b/pkg/services/pluginsintegration/pipeline/pipeline.go
@@ -33,6 +33,9 @@ func ProvideDiscoveryStage(cfg *config.Cfg, pf finder.Finder, pr registry.Servic
func(_ context.Context, _ plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
return NewDisablePluginsStep(cfg).Filter(b)
},
+ func(_ context.Context, c plugins.Class, b []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
+ return NewAsExternalStep(cfg).Filter(c, b)
+ },
},
})
}
diff --git a/pkg/services/pluginsintegration/pipeline/steps.go b/pkg/services/pluginsintegration/pipeline/steps.go
index 34b4eef139b..50e710a1743 100644
--- a/pkg/services/pluginsintegration/pipeline/steps.go
+++ b/pkg/services/pluginsintegration/pipeline/steps.go
@@ -157,3 +157,61 @@ func (c *DisablePlugins) Filter(bundles []*plugins.FoundBundle) ([]*plugins.Foun
}
return res, nil
}
+
+// AsExternal is a filter step that will skip loading a core plugin to use an external one.
+type AsExternal struct {
+ log log.Logger
+ cfg *config.Cfg
+}
+
+// NewDisablePluginsStep returns a new DisablePlugins.
+func NewAsExternalStep(cfg *config.Cfg) *AsExternal {
+ return &AsExternal{
+ cfg: cfg,
+ log: log.New("plugins.asExternal"),
+ }
+}
+
+// Filter will filter out any plugins that are marked to be disabled.
+func (c *AsExternal) Filter(cl plugins.Class, bundles []*plugins.FoundBundle) ([]*plugins.FoundBundle, error) {
+ if c.cfg.Features == nil || !c.cfg.Features.IsEnabled(featuremgmt.FlagExternalCorePlugins) {
+ return bundles, nil
+ }
+
+ if cl == plugins.ClassCore {
+ res := []*plugins.FoundBundle{}
+ for _, bundle := range bundles {
+ pluginCfg := c.cfg.PluginSettings[bundle.Primary.JSONData.ID]
+ // Skip core plugins if the feature flag is enabled and the plugin is in the skip list.
+ // It could be loaded later as an external plugin.
+ if pluginCfg["as_external"] == "true" {
+ c.log.Debug("Skipping the core plugin load", "pluginID", bundle.Primary.JSONData.ID)
+ } else {
+ res = append(res, bundle)
+ }
+ }
+ return res, nil
+ }
+
+ if cl == plugins.ClassExternal {
+ // Warn if the plugin is not found in the external plugins directory.
+ asExternal := map[string]bool{}
+ for pluginID, pluginCfg := range c.cfg.PluginSettings {
+ if pluginCfg["as_external"] == "true" {
+ asExternal[pluginID] = true
+ }
+ }
+ for _, bundle := range bundles {
+ if asExternal[bundle.Primary.JSONData.ID] {
+ delete(asExternal, bundle.Primary.JSONData.ID)
+ }
+ }
+ if len(asExternal) > 0 {
+ for p := range asExternal {
+ c.log.Error("Core plugin expected to be loaded as external, but it is missing", "pluginID", p)
+ }
+ }
+ }
+
+ return bundles, nil
+}
diff --git a/pkg/services/pluginsintegration/pipeline/steps_test.go b/pkg/services/pluginsintegration/pipeline/steps_test.go
index 0b4f1b3f0b2..98c665d0d10 100644
--- a/pkg/services/pluginsintegration/pipeline/steps_test.go
+++ b/pkg/services/pluginsintegration/pipeline/steps_test.go
@@ -5,6 +5,9 @@ import (
"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/services/featuremgmt"
+ "github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
@@ -43,3 +46,59 @@ func TestSkipPlugins(t *testing.T) {
require.Len(t, filtered, 1)
require.Equal(t, filtered[0].Primary.JSONData.ID, "plugin3")
}
+
+func TestAsExternal(t *testing.T) {
+ bundles := []*plugins.FoundBundle{
+ {
+ Primary: plugins.FoundPlugin{
+ JSONData: plugins.JSONData{
+ ID: "plugin1",
+ },
+ },
+ },
+ {
+ Primary: plugins.FoundPlugin{
+ JSONData: plugins.JSONData{
+ ID: "plugin2",
+ },
+ },
+ },
+ }
+
+ t.Run("should skip a core plugin", func(t *testing.T) {
+ cfg := &config.Cfg{
+ Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalCorePlugins),
+ PluginSettings: setting.PluginSettings{
+ "plugin1": map[string]string{
+ "as_external": "true",
+ },
+ },
+ }
+
+ s := NewAsExternalStep(cfg)
+ filtered, err := s.Filter(plugins.ClassCore, bundles)
+ require.NoError(t, err)
+ require.Len(t, filtered, 1)
+ require.Equal(t, filtered[0].Primary.JSONData.ID, "plugin2")
+ })
+
+ t.Run("should log an error if an external plugin is not available", func(t *testing.T) {
+ cfg := &config.Cfg{
+ Features: featuremgmt.WithFeatures(featuremgmt.FlagExternalCorePlugins),
+ PluginSettings: setting.PluginSettings{
+ "plugin3": map[string]string{
+ "as_external": "true",
+ },
+ },
+ }
+
+ fakeLogger := log.NewTestLogger()
+ s := NewAsExternalStep(cfg)
+ s.log = fakeLogger
+
+ filtered, err := s.Filter(plugins.ClassExternal, bundles)
+ require.NoError(t, err)
+ require.Len(t, filtered, 2)
+ require.Equal(t, fakeLogger.ErrorLogs.Calls, 1)
+ })
+}