From a415c0b83184ad473b3f8e10c0319c46d9e5c515 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Thu, 7 Nov 2024 13:14:25 +0100 Subject: [PATCH] Preinstall: Automatic updates if no version is specified (#95970) --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 6 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 12 + .../plugininstaller/service.go | 61 +++- .../plugininstaller/service_test.go | 322 ++++++++---------- 8 files changed, 218 insertions(+), 190 deletions(-) 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 9ae8ccd9616..eced1cdb1dd 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -215,6 +215,7 @@ Experimental features might be changed or removed without prior notice. | `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage | | `timeRangeProvider` | Enables time pickers sync | | `prometheusUsesCombobox` | Use new combobox component for Prometheus query editor | +| `preinstallAutoUpdate` | Enables automatic updates for pre-installed plugins | | `dashboardSchemaV2` | Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. | | `playlistsWatcher` | Enables experimental watcher for playlists | | `enableExtensionsAdminPage` | Enables the extension admin page regardless of development mode | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index d6ef14073f6..31598feb40d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -227,6 +227,7 @@ export interface FeatureToggles { timeRangeProvider?: boolean; prometheusUsesCombobox?: boolean; azureMonitorDisableLogLimit?: boolean; + preinstallAutoUpdate?: boolean; dashboardSchemaV2?: boolean; playlistsWatcher?: boolean; exploreMetricsRelatedLogs?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index a067249e724..56205974dd2 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1567,6 +1567,12 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, + { + Name: "preinstallAutoUpdate", + Description: "Enables automatic updates for pre-installed plugins", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + }, { Name: "dashboardSchemaV2", Description: "Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 86df3456aa6..4c08229efa4 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -208,6 +208,7 @@ unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,f timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false prometheusUsesCombobox,experimental,@grafana/observability-metrics,false,false,false azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false +preinstallAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false dashboardSchemaV2,experimental,@grafana/dashboards-squad,false,false,true playlistsWatcher,experimental,@grafana/grafana-app-platform-squad,false,true,false exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 84c6574e0fb..aaed13765e3 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -843,6 +843,10 @@ const ( // Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. FlagAzureMonitorDisableLogLimit = "azureMonitorDisableLogLimit" + // FlagPreinstallAutoUpdate + // Enables automatic updates for pre-installed plugins + FlagPreinstallAutoUpdate = "preinstallAutoUpdate" + // FlagDashboardSchemaV2 // Enables the new dashboard schema version 2, implementing changes necessary for dynamic dashboards and dashboards as code. FlagDashboardSchemaV2 = "dashboardSchemaV2" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 80d3ef1f15e..b4fc915f062 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2557,6 +2557,18 @@ "codeowner": "@grafana/plugins-platform-backend" } }, + { + "metadata": { + "name": "preinstallAutoUpdate", + "resourceVersion": "1730904343525", + "creationTimestamp": "2024-11-06T14:45:43Z" + }, + "spec": { + "description": "Enables automatic updates for pre-installed plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, { "metadata": { "name": "preserveDashboardStateWhenNavigating", diff --git a/pkg/services/pluginsintegration/plugininstaller/service.go b/pkg/services/pluginsintegration/plugininstaller/service.go index b7ff631f8f5..b8d0b4e37f9 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service.go +++ b/pkg/services/pluginsintegration/plugininstaller/service.go @@ -8,9 +8,11 @@ import ( "sync" "time" + "github.com/Masterminds/semver/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/repo" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" @@ -38,10 +40,19 @@ type Service struct { log log.Logger pluginInstaller plugins.Installer pluginStore pluginstore.Store + pluginRepo repo.Service + features featuremgmt.FeatureToggles failOnErr bool } -func ProvideService(cfg *setting.Cfg, pluginStore pluginstore.Store, pluginInstaller plugins.Installer, promReg prometheus.Registerer) (*Service, error) { +func ProvideService( + cfg *setting.Cfg, + pluginStore pluginstore.Store, + pluginInstaller plugins.Installer, + promReg prometheus.Registerer, + pluginRepo repo.Service, + features featuremgmt.FeatureToggles, +) (*Service, error) { once.Do(func() { promReg.MustRegister(installRequestCounter) promReg.MustRegister(installRequestDuration) @@ -53,6 +64,8 @@ func ProvideService(cfg *setting.Cfg, pluginStore pluginstore.Store, pluginInsta pluginInstaller: pluginInstaller, pluginStore: pluginStore, failOnErr: !cfg.PreinstallPluginsAsync, // Fail on error if preinstall is synchronous + pluginRepo: pluginRepo, + features: features, } if !cfg.PreinstallPluginsAsync { // Block initialization process until plugins are installed @@ -88,6 +101,39 @@ func (s *Service) installPluginsWithTimeout() error { } } +func (s *Service) shouldUpdate(ctx context.Context, pluginID, currentVersion string) bool { + info, err := s.pluginRepo.GetPluginArchiveInfo(ctx, pluginID, "", repo.NewCompatOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH)) + if err != nil { + s.log.Error("Failed to get plugin info", "pluginId", pluginID, "error", err) + return false + } + + // If we are already on the latest version, skip the installation + if info.Version == currentVersion { + s.log.Debug("Latest plugin already installed", "pluginId", pluginID, "version", info.Version) + return false + } + + // If the latest version is a new major version, skip the installation + parsedLatestVersion, err := semver.NewVersion(info.Version) + if err != nil { + s.log.Error("Failed to parse latest version, skipping potential update", "pluginId", pluginID, "version", info.Version, "error", err) + return false + } + parsedCurrentVersion, err := semver.NewVersion(currentVersion) + if err != nil { + s.log.Error("Failed to parse current version, skipping potential update", "pluginId", pluginID, "version", currentVersion, "error", err) + return false + } + if parsedLatestVersion.Major() > parsedCurrentVersion.Major() { + s.log.Debug("New major version available, skipping update due to possible breaking changes", "pluginId", pluginID, "version", info.Version) + return false + } + + // We should update the plugin + return true +} + func (s *Service) installPlugins(ctx context.Context) error { compatOpts := plugins.NewCompatOpts(s.cfg.BuildVersion, runtime.GOOS, runtime.GOARCH) @@ -96,10 +142,21 @@ func (s *Service) installPlugins(ctx context.Context) error { 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 { + if p.Info.Version == installPlugin.Version { s.log.Debug("Plugin already installed", "pluginId", installPlugin.ID, "version", installPlugin.Version) continue } + if installPlugin.Version == "" { + if !s.features.IsEnabled(ctx, featuremgmt.FlagPreinstallAutoUpdate) { + // Skip updating the plugin if the feature flag is disabled + continue + } + // The plugin is installed but it's not pinned to a specific version + // Check if there is a newer version available + if !s.shouldUpdate(ctx, installPlugin.ID, p.Info.Version) { + continue + } + } } s.log.Info("Installing plugin", "pluginId", installPlugin.ID, "version", installPlugin.Version) diff --git a/pkg/services/pluginsintegration/plugininstaller/service_test.go b/pkg/services/pluginsintegration/plugininstaller/service_test.go index 054f1d470b5..6ec4ec516ec 100644 --- a/pkg/services/pluginsintegration/plugininstaller/service_test.go +++ b/pkg/services/pluginsintegration/plugininstaller/service_test.go @@ -2,11 +2,14 @@ package plugininstaller import ( "context" + "errors" "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/plugins/repo" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" "github.com/prometheus/client_golang/prometheus" @@ -24,6 +27,8 @@ func TestService_IsDisabled(t *testing.T) { pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), &fakes.FakePluginInstaller{}, prometheus.NewRegistry(), + &fakes.FakePluginRepo{}, + featuremgmt.WithFeatures(), ) require.NoError(t, err) @@ -34,196 +39,137 @@ func TestService_IsDisabled(t *testing.T) { } func TestService_Run(t *testing.T) { - t.Run("Installs a plugin", func(t *testing.T) { - installed := false - s, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, - }, - 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 + tests := []struct { + name string + shouldInstall bool + pluginsToInstall []setting.InstallPlugin + existingPlugins []*plugins.Plugin + pluginsToFail []string + blocking bool + latestPlugin *repo.PluginArchiveInfo + }{ + { + name: "Installs a plugin", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin"}}, + }, + { + name: "Install a plugin with version", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", Version: "1.0.0"}}, + }, + { + name: "Skips already installed plugin", + shouldInstall: false, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin"}}, + existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin"}}}, + }, + { + name: "Still installs a plugin if the plugin version does not match", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", Version: "2.0.0"}}, + existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin", Info: plugins.Info{Version: "1.0.0"}}}}, + }, + { + name: "Install multiple plugins", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin1"}, {ID: "myplugin2"}}, + }, + { + name: "Fails to install a plugin but install the rest", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin1"}, {ID: "myplugin2"}}, + pluginsToFail: []string{"myplugin1"}, + }, + { + name: "Install a blocking plugin", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin"}}, + blocking: true, + }, + { + name: "Fails to install a blocking plugin", + shouldInstall: false, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin"}}, + blocking: true, + pluginsToFail: []string{"myplugin"}, + }, + { + name: "Updates a plugin", + shouldInstall: true, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", Version: ""}}, + existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin", Info: plugins.Info{Version: "1.0.0"}}}}, + latestPlugin: &repo.PluginArchiveInfo{Version: "1.0.1"}, + }, + { + name: "Should not update a plugin if the latest version is installed", + shouldInstall: false, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", Version: ""}}, + existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin", Info: plugins.Info{Version: "1.0.0"}}}}, + latestPlugin: &repo.PluginArchiveInfo{Version: "1.0.0"}, + }, + { + name: "Should not update a plugin if the latest version is a major version", + shouldInstall: false, + pluginsToInstall: []setting.InstallPlugin{{ID: "myplugin", Version: ""}}, + existingPlugins: []*plugins.Plugin{{JSONData: plugins.JSONData{ID: "myplugin", Info: plugins.Info{Version: "1.0.0"}}}}, + latestPlugin: &repo.PluginArchiveInfo{Version: "2.0.0"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + preg := registry.NewInMemory() + for _, plugin := range tt.existingPlugins { + err := preg.Add(context.Background(), plugin) + require.NoError(t, err) + } + installed := 0 + s, err := ProvideService( + &setting.Cfg{ + PreinstallPlugins: tt.pluginsToInstall, + PreinstallPluginsAsync: !tt.blocking, }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) - - 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, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin", Version: "1.0.0"}}, - PreinstallPluginsAsync: true, - }, - 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 + pluginstore.New(preg, &fakes.FakeLoader{}), + &fakes.FakePluginInstaller{ + AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { + for _, plugin := range tt.pluginsToFail { + if plugin == pluginID { + return errors.New("Failed to install plugin") + } + } + if !tt.shouldInstall { + t.Fatal("Should not install plugin") + return errors.New("Should not install plugin") + } + for _, plugin := range tt.pluginsToInstall { + if plugin.ID == pluginID && plugin.Version == version { + installed++ + } + } + return nil + }, }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) + prometheus.NewRegistry(), + &fakes.FakePluginRepo{ + GetPluginArchiveInfoFunc: func(_ context.Context, pluginID, version string, _ repo.CompatOpts) (*repo.PluginArchiveInfo, error) { + return tt.latestPlugin, nil + }, + }, + featuremgmt.WithFeatures(featuremgmt.FlagPreinstallAutoUpdate), + ) + if tt.blocking && !tt.shouldInstall { + require.ErrorContains(t, err, "Failed to install plugin") + } else { + require.NoError(t, err) + } - 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", - }, + if !tt.blocking { + err = s.Run(context.Background()) + require.NoError(t, err) + } + if tt.shouldInstall { + require.Equal(t, len(tt.pluginsToInstall)-len(tt.pluginsToFail), installed) + } }) - require.NoError(t, err) - s, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, - PreinstallPluginsAsync: true, - }, - 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{} - }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) - - 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, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin", Version: "2.0.0"}}, - PreinstallPluginsAsync: true, - }, - pluginstore.New(preg, &fakes.FakeLoader{}), - &fakes.FakePluginInstaller{ - AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { - installed = true - return nil - }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) - - 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, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin1"}, {ID: "myplugin2"}}, - PreinstallPluginsAsync: true, - }, - pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), - &fakes.FakePluginInstaller{ - AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { - installed++ - return nil - }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) - - 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, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin1"}, {ID: "myplugin2"}}, - PreinstallPluginsAsync: true, - }, - 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 - }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) - err = s.Run(context.Background()) - require.NoError(t, err) - require.Equal(t, 1, installed) - }) - - t.Run("Install a blocking plugin", func(t *testing.T) { - installed := false - _, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, - PreinstallPluginsAsync: false, - }, - 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 - }, - }, - prometheus.NewRegistry(), - ) - require.NoError(t, err) - require.True(t, installed) - }) - - t.Run("Fails to install a blocking plugin", func(t *testing.T) { - _, err := ProvideService( - &setting.Cfg{ - PreinstallPlugins: []setting.InstallPlugin{{ID: "myplugin"}}, - PreinstallPluginsAsync: false, - }, - pluginstore.New(registry.NewInMemory(), &fakes.FakeLoader{}), - &fakes.FakePluginInstaller{ - AddFunc: func(ctx context.Context, pluginID string, version string, opts plugins.CompatOpts) error { - return plugins.NotFoundError{} - }, - }, - prometheus.NewRegistry(), - ) - require.ErrorAs(t, err, &plugins.NotFoundError{}) - }) + } }