diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index 67ac1b3e4ee..cf263bf7c5c 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -21,7 +21,7 @@ import ( type FakePluginInstaller struct { AddFunc func(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error // Remove removes a plugin from the store. - RemoveFunc func(ctx context.Context, pluginID string) error + RemoveFunc func(ctx context.Context, pluginID, version string) error } func (i *FakePluginInstaller) Add(ctx context.Context, pluginID, version string, opts plugins.CompatOpts) error { @@ -31,9 +31,9 @@ func (i *FakePluginInstaller) Add(ctx context.Context, pluginID, version string, return nil } -func (i *FakePluginInstaller) Remove(ctx context.Context, pluginID string) error { +func (i *FakePluginInstaller) Remove(ctx context.Context, pluginID, version string) error { if i.RemoveFunc != nil { - return i.RemoveFunc(ctx, pluginID) + return i.RemoveFunc(ctx, pluginID, version) } return nil } diff --git a/pkg/services/pluginsintegration/plugininstaller/service.go b/pkg/services/pluginsintegration/plugininstaller/service.go index 31d9127eb36..76f993f2b28 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service.go +++ b/pkg/services/pluginsintegration/plugininstaller/service.go @@ -2,31 +2,68 @@ package plugininstaller import ( "context" + "errors" + "runtime" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" ) type Service struct { - features featuremgmt.FeatureToggles - log log.Logger + cfg *setting.Cfg + features featuremgmt.FeatureToggles + log log.Logger + pluginInstaller plugins.Installer + pluginStore pluginstore.Store } -func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles) *Service { +func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles, pluginStore pluginstore.Store, pluginInstaller plugins.Installer) *Service { s := &Service{ - features: features, - log: log.New("plugin.installer"), + features: features, + log: log.New("plugin.backgroundinstaller"), + cfg: cfg, + pluginInstaller: pluginInstaller, + pluginStore: pluginStore, } return s } // IsDisabled disables background installation of plugins. func (s *Service) IsDisabled() bool { - return !s.features.IsEnabled(context.Background(), featuremgmt.FlagBackgroundPluginInstaller) + return !s.features.IsEnabled(context.Background(), featuremgmt.FlagBackgroundPluginInstaller) || + len(s.cfg.InstallPlugins) == 0 } func (s *Service) Run(ctx context.Context) error { - s.log.Debug("PluginInstaller.Run not implemented") + compatOpts := plugins.NewCompatOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) + + for _, installPlugin := range s.cfg.InstallPlugins { + // Check if the plugin is already installed + p, exists := s.pluginStore.Plugin(ctx, installPlugin.ID) + if exists { + // If it's installed, check if we are looking for a specific version + if installPlugin.Version == "" || p.Info.Version == installPlugin.Version { + s.log.Debug("Plugin already installed", "pluginId", installPlugin.ID, "version", installPlugin.Version) + continue + } + } + + s.log.Info("Installing plugin", "pluginId", installPlugin.ID, "version", installPlugin.Version) + err := s.pluginInstaller.Add(ctx, installPlugin.ID, installPlugin.Version, compatOpts) + if err != nil { + var dupeErr plugins.DuplicateError + if errors.As(err, &dupeErr) { + s.log.Debug("Plugin already installed", "pluginId", installPlugin.ID, "version", installPlugin.Version) + continue + } + s.log.Error("Failed to install plugin", "pluginId", installPlugin.ID, "version", installPlugin.Version, "error", err) + continue + } + s.log.Info("Plugin successfully installed", "pluginId", installPlugin.ID, "version", installPlugin.Version) + } + return nil } diff --git a/pkg/services/pluginsintegration/plugininstaller/service_test.go b/pkg/services/pluginsintegration/plugininstaller/service_test.go index 09ad3dfdfa6..19cc3386c6b 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service_test.go +++ b/pkg/services/pluginsintegration/plugininstaller/service_test.go @@ -1,20 +1,179 @@ package plugininstaller import ( + "context" "testing" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/manager/fakes" + "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) // Test if the service is disabled func TestService_IsDisabled(t *testing.T) { // Create a new service - s := &Service{ - features: featuremgmt.WithFeatures(featuremgmt.FlagBackgroundPluginInstaller), - } + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, + }, + featuremgmt.WithFeatures(featuremgmt.FlagBackgroundPluginInstaller), + pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{}, + ) // Check if the service is disabled if s.IsDisabled() { t.Error("Service should be enabled") } } + +func TestService_Run(t *testing.T) { + t.Run("Installs a plugin", func(t *testing.T) { + installed := false + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, + }, + featuremgmt.WithFeatures(), + pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + installed = true + return nil + }, + }, + ) + + err := s.Run(context.Background()) + require.NoError(t, err) + require.True(t, installed) + }) + + t.Run("Install a plugin with version", func(t *testing.T) { + installed := false + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin", Version: "1.0.0"}}, + }, + featuremgmt.WithFeatures(), + pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + if pluginID == "myplugin" && version == "1.0.0" { + installed = true + } + return nil + }, + }, + ) + + err := s.Run(context.Background()) + require.NoError(t, err) + require.True(t, installed) + }) + + t.Run("Skips already installed plugin", func(t *testing.T) { + preg := registry.NewInMemory() + err := preg.Add(context.Background(), &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: "myplugin", + }, + }) + require.NoError(t, err) + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, + }, + featuremgmt.WithFeatures(), + pluginstore.New(preg, &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + t.Fatal("Should not install plugin") + return plugins.DuplicateError{} + }, + }, + ) + + err = s.Run(context.Background()) + require.NoError(t, err) + }) + + t.Run("Still installs a plugin if the plugin version does not match", func(t *testing.T) { + installed := false + preg := registry.NewInMemory() + err := preg.Add(context.Background(), &plugins.Plugin{ + JSONData: plugins.JSONData{ + ID: "myplugin", + Info: plugins.Info{ + Version: "1.0.0", + }, + }, + }) + require.NoError(t, err) + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin", Version: "2.0.0"}}, + }, + featuremgmt.WithFeatures(), + pluginstore.New(preg, &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + installed = true + return nil + }, + }, + ) + + err = s.Run(context.Background()) + require.NoError(t, err) + require.True(t, installed) + }) + + t.Run("Install multiple plugins", func(t *testing.T) { + installed := 0 + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin1"}, {ID: "myplugin2"}}, + }, + featuremgmt.WithFeatures(), + pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + installed++ + return nil + }, + }, + ) + + err := s.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, 2, installed) + }) + + t.Run("Fails to install a plugin but install the rest", func(t *testing.T) { + installed := 0 + s := ProvideService( + &setting.Cfg{ + InstallPlugins: []setting.InstallPlugin{{ID: "myplugin1"}, {ID: "myplugin2"}}, + }, + featuremgmt.WithFeatures(), + pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + if pluginID == "myplugin1" { + return plugins.NotFoundError{} + } + installed++ + return nil + }, + }, + ) + err := s.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, installed) + }) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 1d1b1f7e872..30f4fdca73d 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -198,6 +198,7 @@ type Cfg struct { HideAngularDeprecation []string PluginInstallToken string ForwardHostEnvVars []string + InstallPlugins []InstallPlugin PluginsCDNURLTemplate string PluginLogBackendRequests bool @@ -526,6 +527,11 @@ type Cfg struct { UnifiedStorage map[string]grafanarest.DualWriterMode } +type InstallPlugin struct { + ID string + Version string +} + // AddChangePasswordLink returns if login form is disabled or not since // the same intention can be used to hide both features. func (cfg *Cfg) AddChangePasswordLink() bool { diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 636ad5753f3..baeaca0b393 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -39,6 +39,17 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { cfg.DisablePlugins = util.SplitString(pluginsSection.Key("disable_plugins").MustString("")) cfg.HideAngularDeprecation = util.SplitString(pluginsSection.Key("hide_angular_deprecation").MustString("")) cfg.ForwardHostEnvVars = util.SplitString(pluginsSection.Key("forward_host_env_vars").MustString("")) + rawInstallPlugins := util.SplitString(pluginsSection.Key("install").MustString("")) + cfg.InstallPlugins = make([]InstallPlugin, len(rawInstallPlugins)) + for i, plugin := range rawInstallPlugins { + parts := strings.Split(plugin, "@") + id := parts[0] + v := "" + if len(parts) == 2 { + v = parts[1] + } + cfg.InstallPlugins[i] = InstallPlugin{id, v} + } cfg.PluginCatalogURL = pluginsSection.Key("plugin_catalog_url").MustString("https://grafana.com/grafana/plugins/") cfg.PluginAdminEnabled = pluginsSection.Key("plugin_admin_enabled").MustBool(true)