From f94c0decbd302140fffe351db200634a5c728545 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 20 Jan 2022 13:42:05 -0800 Subject: [PATCH] FeatureFlags: manage feature flags outside of settings.Cfg (#43692) --- .gitignore | 1 + packages/grafana-data/src/types/config.ts | 1 - .../src/types/featureToggles.gen.ts | 6 + .../grafana-runtime/src/utils/licensing.ts | 12 +- pkg/api/api.go | 5 +- pkg/api/apikey.go | 4 +- pkg/api/common_test.go | 32 ++- pkg/api/dashboard_test.go | 12 +- pkg/api/frontendsettings.go | 11 +- pkg/api/frontendsettings_test.go | 14 +- pkg/api/http_server.go | 5 +- pkg/api/index.go | 22 +- pkg/api/org.go | 2 +- pkg/api/org_users_test.go | 5 +- pkg/api/routing/route_register.go | 6 +- pkg/api/team.go | 2 +- pkg/api/team_test.go | 9 +- .../http_client_provider.go | 5 +- .../http_client_provider_test.go | 5 +- pkg/middleware/request_metrics.go | 6 +- pkg/models/licensing.go | 2 - pkg/plugins/manager/dashboard_import_test.go | 1 - pkg/plugins/manager/dashboards_test.go | 1 - .../manager/manager_integration_test.go | 13 +- pkg/server/wire.go | 3 + .../ossaccesscontrol/ossaccesscontrol.go | 12 +- .../ossaccesscontrol/ossaccesscontrol_test.go | 28 +-- pkg/services/featuremgmt/features.go | 95 +++++++++ pkg/services/featuremgmt/manager.go | 195 ++++++++++++++++++ pkg/services/featuremgmt/manager_test.go | 77 +++++++ pkg/services/featuremgmt/registry.go | 163 +++++++++++++++ pkg/services/featuremgmt/service.go | 78 +++++++ pkg/services/featuremgmt/settings.go | 34 +++ pkg/services/featuremgmt/settings_test.go | 25 +++ .../featuremgmt/testdata/features.yaml | 33 +++ .../featuremgmt/testdata/included.yaml | 13 ++ pkg/services/featuremgmt/toggles.go | 10 + pkg/services/featuremgmt/toggles_gen.go | 157 ++++++++++++++ pkg/services/featuremgmt/toggles_gen_test.go | 140 +++++++++++++ pkg/services/live/live.go | 7 +- pkg/services/schemaloader/schemaloader.go | 12 +- pkg/services/secrets/manager/helpers.go | 10 +- pkg/services/secrets/manager/manager_test.go | 5 +- pkg/services/serviceaccounts/api/api.go | 6 +- pkg/services/serviceaccounts/api/api_test.go | 4 +- .../serviceaccounts/manager/service.go | 22 +- .../serviceaccounts/manager/service_test.go | 16 +- .../sqlstore/migrations/migrations.go | 5 +- pkg/services/sqlstore/org_users.go | 4 +- pkg/services/sqlstore/org_users_test.go | 5 +- pkg/services/sqlstore/sqlstore.go | 4 +- pkg/services/thumbs/service.go | 5 +- pkg/setting/provider.go | 2 +- pkg/setting/setting.go | 41 +--- pkg/setting/setting_feature_toggles.go | 41 +--- pkg/setting/setting_feature_toggles_test.go | 20 +- pkg/setting/setting_unified_alerting.go | 2 +- pkg/setting/setting_unified_alerting_test.go | 1 + pkg/tsdb/testdatasource/stream_handler.go | 4 +- pkg/tsdb/testdatasource/testdata.go | 5 +- public/app/core/services/context_srv.ts | 12 +- public/app/core/utils/accessControl.ts | 2 +- .../components/PluginListItemBadges.test.tsx | 4 +- .../admin/pages/PluginDetails.test.tsx | 8 +- public/app/features/teams/TeamPages.test.tsx | 4 +- 65 files changed, 1244 insertions(+), 252 deletions(-) create mode 100644 pkg/services/featuremgmt/features.go create mode 100644 pkg/services/featuremgmt/manager.go create mode 100644 pkg/services/featuremgmt/manager_test.go create mode 100644 pkg/services/featuremgmt/registry.go create mode 100644 pkg/services/featuremgmt/service.go create mode 100644 pkg/services/featuremgmt/settings.go create mode 100644 pkg/services/featuremgmt/settings_test.go create mode 100644 pkg/services/featuremgmt/testdata/features.yaml create mode 100644 pkg/services/featuremgmt/testdata/included.yaml create mode 100644 pkg/services/featuremgmt/toggles.go create mode 100644 pkg/services/featuremgmt/toggles_gen.go create mode 100644 pkg/services/featuremgmt/toggles_gen_test.go diff --git a/.gitignore b/.gitignore index 5705ba37063..2ae86906ce5 100644 --- a/.gitignore +++ b/.gitignore @@ -156,6 +156,7 @@ compilation-stats.json # auto generated Go files *_gen.go +!pkg/services/featuremgmt/toggles_gen.go # Auto-generated localisation files public/locales/_build/ diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index b1d519c8b12..658e7c5939f 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -40,7 +40,6 @@ export interface LicenseInfo { licenseUrl: string; stateInfo: string; edition: GrafanaEdition; - enabledFeatures: { [key: string]: boolean }; } /** diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 94d9a8b6eb3..260b80f56bf 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1,3 +1,9 @@ +// NOTE: This file was auto generated. DO NOT EDIT DIRECTLY! +// To change feature flags, edit: +// pkg/services/featuremgmt/registry.go +// Then run tests in: +// pkg/services/featuremgmt/toggles_gen_test.go + /** * Describes available feature toggles in Grafana. These can be configured via * conf/custom.ini to enable features under development or not yet available in diff --git a/packages/grafana-runtime/src/utils/licensing.ts b/packages/grafana-runtime/src/utils/licensing.ts index 59f1f6f486f..a7c59e23001 100644 --- a/packages/grafana-runtime/src/utils/licensing.ts +++ b/packages/grafana-runtime/src/utils/licensing.ts @@ -1,6 +1,12 @@ +import { FeatureToggles } from '@grafana/data'; import { config } from '../config'; -export const featureEnabled = (feature: string): boolean => { - const { enabledFeatures } = config.licenseInfo; - return enabledFeatures && enabledFeatures[feature]; +export const featureEnabled = (feature: boolean | undefined | keyof FeatureToggles): boolean => { + if (feature === true || feature === false) { + return feature; + } + if (feature == null || !config?.featureToggles) { + return false; + } + return Boolean(config.featureToggles[feature]); }; diff --git a/pkg/api/api.go b/pkg/api/api.go index d097e5baabc..740754d791c 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -437,7 +437,7 @@ func (hs *HTTPServer) registerRoutes() { // Some channels may have info liveRoute.Get("/info/*", routing.Wrap(hs.Live.HandleInfoHTTP)) - if hs.Cfg.FeatureToggles["live-pipeline"] { + if hs.Features.Toggles().IsLivePipelineEnabled() { // POST Live data to be processed according to channel rules. liveRoute.Post("/pipeline/push/*", hs.LivePushGateway.HandlePipelinePush) liveRoute.Post("/pipeline-convert-test", routing.Wrap(hs.Live.HandlePipelineConvertTestHTTP), reqOrgAdmin) @@ -460,6 +460,9 @@ func (hs *HTTPServer) registerRoutes() { // admin api r.Group("/api/admin", func(adminRoute routing.RouteRegister) { adminRoute.Get("/settings", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), routing.Wrap(hs.AdminGetSettings)) + if hs.Features.Toggles().IsShowFeatureFlagsInUIEnabled() { + adminRoute.Get("/settings/features", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)), hs.Features.HandleGetSettings) + } adminRoute.Get("/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), routing.Wrap(AdminGetStats)) adminRoute.Post("/pause-all-alerts", reqGrafanaAdmin, routing.Wrap(PauseAllAlerts)) diff --git a/pkg/api/apikey.go b/pkg/api/apikey.go index f8cb6de67e8..9cede86680c 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -83,7 +83,7 @@ func (hs *HTTPServer) AddAPIKey(c *models.ReqContext) response.Response { } cmd.OrgId = c.OrgId var err error - if hs.Cfg.FeatureToggles["service-accounts"] { + if hs.Features.Toggles().IsServiceAccountsEnabled() { // Api keys should now be created with addadditionalapikey endpoint return response.Error(400, "API keys should now be added via the AdditionalAPIKey endpoint.", err) } @@ -120,7 +120,7 @@ func (hs *HTTPServer) AdditionalAPIKey(c *models.ReqContext) response.Response { if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - if !hs.Cfg.FeatureToggles["service-accounts"] { + if !hs.Features.Toggles().IsServiceAccountsEnabled() { return response.Error(500, "Requires services-accounts feature", errors.New("feature missing")) } diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 9bccf66e97a..64a808facbe 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" "github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/searchusers" @@ -213,8 +214,8 @@ func (s *fakeRenderService) Init() error { } func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url string, permissions []*accesscontrol.Permission) (*scenarioContext, *HTTPServer) { - cfg.FeatureToggles = make(map[string]bool) - cfg.FeatureToggles["accesscontrol"] = true + features := featuremgmt.WithFeatures("accesscontrol") + cfg.IsFeatureToggleEnabled = features.IsEnabled cfg.Quota.Enabled = false bus := bus.GetBus() @@ -222,6 +223,7 @@ func setupAccessControlScenarioContext(t *testing.T, cfg *setting.Cfg, url strin Cfg: cfg, Bus: bus, Live: newTestLive(t), + Features: features, QuotaService: "a.QuotaService{Cfg: cfg}, RouteRegister: routing.NewRouteRegister(), AccessControl: accesscontrolmock.New().WithPermissions(permissions), @@ -296,13 +298,25 @@ func setInitCtxSignedInOrgAdmin(initCtx *models.ReqContext) { initCtx.SignedInUser = &models.SignedInUser{UserId: testUserID, OrgId: 1, OrgRole: models.ROLE_ADMIN, Login: testUserLogin} } +func setupSimpleHTTPServer(features *featuremgmt.FeatureManager) *HTTPServer { + if features == nil { + features = featuremgmt.WithFeatures() + } + cfg := setting.NewCfg() + cfg.IsFeatureToggleEnabled = features.IsEnabled + + return &HTTPServer{ + Cfg: cfg, + Features: features, + Bus: bus.GetBus(), + } +} + func setupHTTPServer(t *testing.T, useFakeAccessControl bool, enableAccessControl bool) accessControlScenarioContext { // Use a new conf + features := featuremgmt.WithFeatures("accesscontrol", enableAccessControl) cfg := setting.NewCfg() - cfg.FeatureToggles = make(map[string]bool) - if enableAccessControl { - cfg.FeatureToggles["accesscontrol"] = enableAccessControl - } + cfg.IsFeatureToggleEnabled = features.IsEnabled return setupHTTPServerWithCfg(t, useFakeAccessControl, enableAccessControl, cfg) } @@ -310,6 +324,9 @@ func setupHTTPServer(t *testing.T, useFakeAccessControl bool, enableAccessContro func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg) accessControlScenarioContext { t.Helper() + features := featuremgmt.WithFeatures("accesscontrol", enableAccessControl) + cfg.IsFeatureToggleEnabled = features.IsEnabled + var acmock *accesscontrolmock.Mock var ac *ossaccesscontrol.OSSAccessControlService @@ -322,6 +339,7 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont // Create minimal HTTP Server hs := &HTTPServer{ Cfg: cfg, + Features: features, Bus: bus, Live: newTestLive(t), QuotaService: "a.QuotaService{Cfg: cfg}, @@ -338,7 +356,7 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont } hs.AccessControl = acmock } else { - ac = ossaccesscontrol.ProvideService(cfg, &usagestats.UsageStatsMock{T: t}) + ac = ossaccesscontrol.ProvideService(hs.Features.Toggles(), &usagestats.UsageStatsMock{T: t}) hs.AccessControl = ac // Perform role registration err := hs.declareFixedRoles() diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 8b23ce788d8..3561dc3cd8c 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/provisioning" @@ -88,8 +89,17 @@ type testState struct { } func newTestLive(t *testing.T) *live.GrafanaLive { + features := featuremgmt.WithToggles() cfg := &setting.Cfg{AppURL: "http://localhost:3000/"} - gLive, err := live.ProvideService(nil, cfg, routing.NewRouteRegister(), nil, nil, nil, sqlstore.InitTestDB(t), nil, &usagestats.UsageStatsMock{T: t}, nil) + cfg.IsFeatureToggleEnabled = features.IsEnabled + gLive, err := live.ProvideService(nil, cfg, + routing.NewRouteRegister(), + nil, nil, nil, + sqlstore.InitTestDB(t), + nil, + &usagestats.UsageStatsMock{T: t}, + nil, + features) require.NoError(t, err) return gLive } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 5919ae77a5a..2ac11f4223d 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -243,13 +243,12 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "env": setting.Env, }, "licenseInfo": map[string]interface{}{ - "expiry": hs.License.Expiry(), - "stateInfo": hs.License.StateInfo(), - "licenseUrl": hs.License.LicenseURL(hasAccess(accesscontrol.ReqGrafanaAdmin, accesscontrol.LicensingPageReaderAccess)), - "edition": hs.License.Edition(), - "enabledFeatures": hs.License.EnabledFeatures(), + "expiry": hs.License.Expiry(), + "stateInfo": hs.License.StateInfo(), + "licenseUrl": hs.License.LicenseURL(hasAccess(accesscontrol.ReqGrafanaAdmin, accesscontrol.LicensingPageReaderAccess)), + "edition": hs.License.Edition(), }, - "featureToggles": hs.Cfg.FeatureToggles, + "featureToggles": hs.Features.GetEnabled(c.Req.Context()), "rendererAvailable": hs.RenderService.IsAvailable(), "rendererVersion": hs.RenderService.Version(), "http2Enabled": hs.Cfg.Protocol == setting.HTTP2Scheme, diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index f873f8ce1b5..c58e0ffdaf7 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/bus" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -19,9 +20,10 @@ import ( "github.com/stretchr/testify/require" ) -func setupTestEnvironment(t *testing.T, cfg *setting.Cfg) (*web.Mux, *HTTPServer) { +func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*web.Mux, *HTTPServer) { t.Helper() sqlstore.InitTestDB(t) + cfg.IsFeatureToggleEnabled = features.IsEnabled { oldVersion := setting.BuildVersion @@ -37,9 +39,10 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg) (*web.Mux, *HTTPServer sqlStore := sqlstore.InitTestDB(t) hs := &HTTPServer{ - Cfg: cfg, - Bus: bus.GetBus(), - License: &licensing.OSSLicensingService{Cfg: cfg}, + Cfg: cfg, + Features: features, + Bus: bus.GetBus(), + License: &licensing.OSSLicensingService{Cfg: cfg}, RenderService: &rendering.RenderingService{ Cfg: cfg, RendererPluginManager: &fakeRendererManager{}, @@ -73,7 +76,8 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) { cfg.Env = "testing" cfg.BuildVersion = "7.8.9" cfg.BuildCommit = "01234567" - m, hs := setupTestEnvironment(t, cfg) + + m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures()) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index e2db5c5957e..3077edb82bc 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -13,6 +13,7 @@ import ( "strings" "sync" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/thumbs" @@ -75,6 +76,7 @@ type HTTPServer struct { Bus bus.Bus RenderService rendering.Service Cfg *setting.Cfg + Features *featuremgmt.FeatureManager SettingsProvider setting.Provider HooksService *hooks.HooksService CacheService *localcache.CacheService @@ -135,7 +137,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi loginService login.Service, accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, - contextHandler *contexthandler.ContextHandler, + contextHandler *contexthandler.ContextHandler, features *featuremgmt.FeatureManager, schemaService *schemaloader.SchemaLoaderService, alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service, quotaService *quota.QuotaService, socialService social.Service, tracer tracing.Tracer, @@ -167,6 +169,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi AuthTokenService: userTokenService, cleanUpService: cleanUpService, ShortURLService: shortURLService, + Features: features, ThumbService: thumbService, RemoteCacheService: remoteCache, ProvisioningService: provisioningService, diff --git a/pkg/api/index.go b/pkg/api/index.go index bf23ec86abc..94c3e2f88e7 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -85,7 +85,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) SortWeight: dtos.WeightPlugin, } - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.Toggles().IsNewNavigationEnabled() { appLink.Section = dtos.NavSectionPlugin } else { appLink.Section = dtos.NavSectionCore @@ -143,7 +143,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) } func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool { - return c.OrgRole == models.ROLE_ADMIN && hs.Cfg.IsServiceAccountEnabled() && hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId) + return c.OrgRole == models.ROLE_ADMIN && hs.Features.Toggles().IsServiceAccountsEnabled() && hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId) } func enableTeams(hs *HTTPServer, c *models.ReqContext) bool { @@ -154,7 +154,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto hasAccess := ac.HasAccess(hs.AccessControl, c) navTree := []*dtos.NavLink{} - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.Toggles().IsNewNavigationEnabled() { navTree = append(navTree, &dtos.NavLink{ Text: "Home", Id: "home", @@ -165,7 +165,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if hasEditPerm && !hs.Cfg.IsNewNavigationEnabled() { + if hasEditPerm && !hs.Features.Toggles().IsNewNavigationEnabled() { children := hs.buildCreateNavLinks(c) navTree = append(navTree, &dtos.NavLink{ Text: "Create", @@ -181,7 +181,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm) dashboardsUrl := "/" - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.Toggles().IsNewNavigationEnabled() { dashboardsUrl = "/dashboards" } @@ -312,7 +312,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if hs.Cfg.FeatureToggles["live-pipeline"] { + if hs.Features.Toggles().IsLivePipelineEnabled() { liveNavLinks := []*dtos.NavLink{} liveNavLinks = append(liveNavLinks, &dtos.NavLink{ @@ -346,7 +346,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto SortWeight: dtos.WeightConfig, Children: configNodes, } - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.Toggles().IsNewNavigationEnabled() { configNode.Section = dtos.NavSectionConfig } else { configNode.Section = dtos.NavSectionCore @@ -358,7 +358,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto if len(adminNavLinks) > 0 { navSection := dtos.NavSectionCore - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.Toggles().IsNewNavigationEnabled() { navSection = dtos.NavSectionConfig } serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks, navSection) @@ -386,7 +386,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*dtos.NavLink { dashboardChildNavs := []*dtos.NavLink{} - if !hs.Cfg.IsNewNavigationEnabled() { + if !hs.Features.Toggles().IsNewNavigationEnabled() { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Home", Id: "home", Url: hs.Cfg.AppSubURL + "/", Icon: "home-alt", HideFromTabs: true, }) @@ -417,7 +417,7 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b }) } - if hasEditPerm && hs.Cfg.IsNewNavigationEnabled() { + if hasEditPerm && hs.Features.Toggles().IsNewNavigationEnabled() { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) @@ -622,7 +622,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat LoadingLogo: "public/img/grafana_icon.svg", } - if hs.Cfg.FeatureToggles["accesscontrol"] { + if hs.Features.Toggles().IsAccesscontrolEnabled() { userPermissions, err := hs.AccessControl.GetUserPermissions(c.Req.Context(), c.SignedInUser) if err != nil { return nil, err diff --git a/pkg/api/org.go b/pkg/api/org.go index d2f2d6e6e22..e8d7ccf57b6 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -89,7 +89,7 @@ func (hs *HTTPServer) CreateOrg(c *models.ReqContext) response.Response { if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - acEnabled := hs.Cfg.FeatureToggles["accesscontrol"] + acEnabled := hs.Features.Toggles().IsAccesscontrolEnabled() if !acEnabled && !(setting.AllowUserOrgCreate || c.IsGrafanaAdmin) { return response.Error(403, "Access denied", nil) } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index a208fec07e1..fd42234c6ae 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -34,8 +35,8 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore *sqlstore.SQLStore) { } func TestOrgUsersAPIEndpoint_userLoggedIn(t *testing.T) { - settings := setting.NewCfg() - hs := &HTTPServer{Cfg: settings} + hs := setupSimpleHTTPServer(featuremgmt.WithFeatures()) + settings := hs.Cfg sqlStore := sqlstore.InitTestDB(t) sqlStore.Cfg = settings diff --git a/pkg/api/routing/route_register.go b/pkg/api/routing/route_register.go index 7ae07933250..5dc3419efec 100644 --- a/pkg/api/routing/route_register.go +++ b/pkg/api/routing/route_register.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/grafana/grafana/pkg/middleware" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" ) @@ -52,8 +52,8 @@ type RouteRegister interface { type RegisterNamedMiddleware func(name string) web.Handler -func ProvideRegister(cfg *setting.Cfg) *RouteRegisterImpl { - return NewRouteRegister(middleware.ProvideRouteOperationName, middleware.RequestMetrics(cfg)) +func ProvideRegister(features *featuremgmt.FeatureToggles) *RouteRegisterImpl { + return NewRouteRegister(middleware.ProvideRouteOperationName, middleware.RequestMetrics(features)) } // NewRouteRegister creates a new RouteRegister with all middlewares sent as params diff --git a/pkg/api/team.go b/pkg/api/team.go index 92553554a33..ee22c66db7e 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -20,7 +20,7 @@ func (hs *HTTPServer) CreateTeam(c *models.ReqContext) response.Response { if err := web.Bind(c.Req, &cmd); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } - accessControlEnabled := hs.Cfg.FeatureToggles["accesscontrol"] + accessControlEnabled := hs.Features.Toggles().IsAccesscontrolEnabled() if !accessControlEnabled && c.OrgRole == models.ROLE_VIEWER { return response.Error(403, "Not allowed to create team.", nil) } diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index aa959f7b5be..a09a5cb9372 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -40,9 +40,7 @@ func TestTeamAPIEndpoint(t *testing.T) { TotalCount: 2, } - hs := &HTTPServer{ - Cfg: setting.NewCfg(), - } + hs := setupSimpleHTTPServer(nil) loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) { var sentLimit int @@ -92,10 +90,7 @@ func TestTeamAPIEndpoint(t *testing.T) { t.Run("When creating team with API key", func(t *testing.T) { defer bus.ClearBusHandlers() - hs := &HTTPServer{ - Cfg: setting.NewCfg(), - Bus: bus.GetBus(), - } + hs := setupSimpleHTTPServer(nil) hs.Cfg.EditorsCanAdmin = true teamName := "team foo" diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index d0995552dda..97e3073f35a 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/mwitkow/go-conntrack" ) @@ -16,7 +17,7 @@ import ( var newProviderFunc = sdkhttpclient.NewProvider // New creates a new HTTP client provider with pre-configured middlewares. -func New(cfg *setting.Cfg, tracer tracing.Tracer) *sdkhttpclient.Provider { +func New(cfg *setting.Cfg, tracer tracing.Tracer, features *featuremgmt.FeatureToggles) *sdkhttpclient.Provider { logger := log.New("httpclient") userAgent := fmt.Sprintf("Grafana/%s", cfg.BuildVersion) @@ -35,7 +36,7 @@ func New(cfg *setting.Cfg, tracer tracing.Tracer) *sdkhttpclient.Provider { setDefaultTimeoutOptions(cfg) - if cfg.FeatureToggles["httpclientprovider_azure_auth"] { + if features.IsHttpclientproviderAzureAuthEnabled() { middlewares = append(middlewares, AzureMiddleware(cfg)) } diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go index d00deedf3bf..4011ebc6816 100644 --- a/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go +++ b/pkg/infra/httpclient/httpclientprovider/http_client_provider_test.go @@ -5,6 +5,7 @@ import ( sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" ) @@ -22,7 +23,7 @@ func TestHTTPClientProvider(t *testing.T) { }) tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - _ = New(&setting.Cfg{SigV4AuthEnabled: false}, tracer) + _ = New(&setting.Cfg{SigV4AuthEnabled: false}, tracer, featuremgmt.WithToggles()) require.Len(t, providerOpts, 1) o := providerOpts[0] require.Len(t, o.Middlewares, 6) @@ -46,7 +47,7 @@ func TestHTTPClientProvider(t *testing.T) { }) tracer, err := tracing.InitializeTracerForTest() require.NoError(t, err) - _ = New(&setting.Cfg{SigV4AuthEnabled: true}, tracer) + _ = New(&setting.Cfg{SigV4AuthEnabled: true}, tracer, featuremgmt.WithToggles()) require.Len(t, providerOpts, 1) o := providerOpts[0] require.Len(t, o.Middlewares, 7) diff --git a/pkg/middleware/request_metrics.go b/pkg/middleware/request_metrics.go index a29ef94f0a5..29c429abfc4 100644 --- a/pkg/middleware/request_metrics.go +++ b/pkg/middleware/request_metrics.go @@ -7,7 +7,7 @@ import ( "time" "github.com/grafana/grafana/pkg/infra/metrics" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" "github.com/prometheus/client_golang/prometheus" cw "github.com/weaveworks/common/tracing" @@ -45,7 +45,7 @@ func init() { } // RequestMetrics is a middleware handler that instruments the request. -func RequestMetrics(cfg *setting.Cfg) func(handler string) web.Handler { +func RequestMetrics(features *featuremgmt.FeatureToggles) func(handler string) web.Handler { return func(handler string) web.Handler { return func(res http.ResponseWriter, req *http.Request, c *web.Context) { rw := res.(web.ResponseWriter) @@ -60,7 +60,7 @@ func RequestMetrics(cfg *setting.Cfg) func(handler string) web.Handler { method := sanitizeMethod(req.Method) // enable histogram and disable summaries + counters for http requests. - if cfg.IsHTTPRequestHistogramDisabled() { + if features.IsDisableHttpRequestHistogramEnabled() { duration := time.Since(now).Nanoseconds() / int64(time.Millisecond) metrics.MHttpRequestTotal.WithLabelValues(handler, code, method).Inc() metrics.MHttpRequestSummary.WithLabelValues(handler, code, method).Observe(float64(duration)) diff --git a/pkg/models/licensing.go b/pkg/models/licensing.go index e49eb7a3288..9a4da445105 100644 --- a/pkg/models/licensing.go +++ b/pkg/models/licensing.go @@ -14,8 +14,6 @@ type Licensing interface { StateInfo() string - EnabledFeatures() map[string]bool - FeatureEnabled(feature string) bool } diff --git a/pkg/plugins/manager/dashboard_import_test.go b/pkg/plugins/manager/dashboard_import_test.go index f42bc070494..3043a826cac 100644 --- a/pkg/plugins/manager/dashboard_import_test.go +++ b/pkg/plugins/manager/dashboard_import_test.go @@ -85,7 +85,6 @@ func pluginScenario(t *testing.T, desc string, fn func(*testing.T, *PluginManage t.Run("Given a plugin", func(t *testing.T) { cfg := &setting.Cfg{ - FeatureToggles: map[string]bool{}, PluginSettings: setting.PluginSettings{ "test-app": map[string]string{ "path": "testdata/test-app", diff --git a/pkg/plugins/manager/dashboards_test.go b/pkg/plugins/manager/dashboards_test.go index bc384e81234..c3a834de961 100644 --- a/pkg/plugins/manager/dashboards_test.go +++ b/pkg/plugins/manager/dashboards_test.go @@ -18,7 +18,6 @@ import ( func TestGetPluginDashboards(t *testing.T) { cfg := &setting.Cfg{ - FeatureToggles: map[string]bool{}, PluginSettings: setting.PluginSettings{ "test-app": map[string]string{ "path": "testdata/test-app", diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index e421b163da7..06651586e66 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/manager/loader" "github.com/grafana/grafana/pkg/plugins/manager/signature" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -50,11 +51,13 @@ func TestPluginManager_int_init(t *testing.T) { bundledPluginsPath, err := filepath.Abs("../../../plugins-bundled/internal") require.NoError(t, err) + features := featuremgmt.WithToggles() cfg := &setting.Cfg{ - Raw: ini.Empty(), - Env: setting.Prod, - StaticRootPath: staticRootPath, - BundledPluginsPath: bundledPluginsPath, + Raw: ini.Empty(), + Env: setting.Prod, + StaticRootPath: staticRootPath, + BundledPluginsPath: bundledPluginsPath, + IsFeatureToggleEnabled: features.IsEnabled, PluginSettings: map[string]map[string]string{ "plugin.datasource-id": { "path": "testdata/test-app", @@ -79,7 +82,7 @@ func TestPluginManager_int_init(t *testing.T) { otsdb := opentsdb.ProvideService(hcp) pr := prometheus.ProvideService(hcp, tracer) tmpo := tempo.ProvideService(hcp) - td := testdatasource.ProvideService(cfg) + td := testdatasource.ProvideService(cfg, features) pg := postgres.ProvideService(cfg) my := mysql.ProvideService(cfg, hcp) ms := mssql.ProvideService(cfg) diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 986e78fb74b..6761814df2c 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -35,6 +35,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/librarypanels" @@ -182,6 +183,8 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(teamguardian.Store), new(*teamguardianDatabase.TeamGuardianStoreImpl)), teamguardianManager.ProvideService, wire.Bind(new(teamguardian.TeamGuardian), new(*teamguardianManager.Service)), + featuremgmt.ProvideManagerService, + featuremgmt.ProvideToggles, ) var wireSet = wire.NewSet( diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 020262d24f1..1ca972cfc8d 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go @@ -9,13 +9,13 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/prometheus/client_golang/prometheus" ) -func ProvideService(cfg *setting.Cfg, usageStats usagestats.Service) *OSSAccessControlService { +func ProvideService(features *featuremgmt.FeatureToggles, usageStats usagestats.Service) *OSSAccessControlService { s := &OSSAccessControlService{ - Cfg: cfg, + features: features, UsageStats: usageStats, Log: log.New("accesscontrol"), ScopeResolver: accesscontrol.NewScopeResolver(), @@ -26,7 +26,7 @@ func ProvideService(cfg *setting.Cfg, usageStats usagestats.Service) *OSSAccessC // OSSAccessControlService is the service implementing role based access control. type OSSAccessControlService struct { - Cfg *setting.Cfg + features *featuremgmt.FeatureToggles UsageStats usagestats.Service Log log.Logger registrations accesscontrol.RegistrationList @@ -34,10 +34,10 @@ type OSSAccessControlService struct { } func (ac *OSSAccessControlService) IsDisabled() bool { - if ac.Cfg == nil { + if ac.features == nil { return true } - return !ac.Cfg.FeatureToggles["accesscontrol"] + return !ac.features.IsAccesscontrolEnabled() } func (ac *OSSAccessControlService) registerUsageMetrics() { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go index cb1710e2779..dd85c0658b4 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go @@ -12,17 +12,14 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func setupTestEnv(t testing.TB) *OSSAccessControlService { t.Helper() - cfg := setting.NewCfg() - cfg.FeatureToggles = map[string]bool{"accesscontrol": true} - ac := &OSSAccessControlService{ - Cfg: cfg, + features: featuremgmt.WithToggles("accesscontrol"), UsageStats: &usagestats.UsageStatsMock{T: t}, Log: log.New("accesscontrol"), registrations: accesscontrol.RegistrationList{}, @@ -148,12 +145,9 @@ func TestUsageMetrics(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := setting.NewCfg() - if tt.enabled { - cfg.FeatureToggles = map[string]bool{"accesscontrol": true} - } + features := featuremgmt.WithToggles("accesscontrol", tt.enabled) - s := ProvideService(cfg, &usagestats.UsageStatsMock{T: t}) + s := ProvideService(features, &usagestats.UsageStatsMock{T: t}) report, err := s.UsageStats.GetUsageReport(context.Background()) assert.Nil(t, err) @@ -267,7 +261,7 @@ func TestOSSAccessControlService_RegisterFixedRole(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ac := &OSSAccessControlService{ - Cfg: setting.NewCfg(), + features: featuremgmt.WithToggles(), UsageStats: &usagestats.UsageStatsMock{T: t}, Log: log.New("accesscontrol-test"), } @@ -386,12 +380,11 @@ func TestOSSAccessControlService_DeclareFixedRoles(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ac := &OSSAccessControlService{ - Cfg: setting.NewCfg(), + features: featuremgmt.WithToggles("accesscontrol"), UsageStats: &usagestats.UsageStatsMock{T: t}, Log: log.New("accesscontrol-test"), registrations: accesscontrol.RegistrationList{}, } - ac.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true} // Test err := ac.DeclareFixedRoles(tt.registrations...) @@ -459,9 +452,6 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { } for _, tt := range tests { - cfg := setting.NewCfg() - cfg.FeatureToggles = map[string]bool{"accesscontrol": true} - t.Run(tt.name, func(t *testing.T) { // Remove any inserted role after the test case has been run t.Cleanup(func() { @@ -472,12 +462,11 @@ func TestOSSAccessControlService_RegisterFixedRoles(t *testing.T) { // Setup ac := &OSSAccessControlService{ - Cfg: setting.NewCfg(), + features: featuremgmt.WithToggles("accesscontrol"), UsageStats: &usagestats.UsageStatsMock{T: t}, Log: log.New("accesscontrol-test"), registrations: accesscontrol.RegistrationList{}, } - ac.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true} ac.registrations.Append(tt.registrations...) // Test @@ -552,7 +541,7 @@ func TestOSSAccessControlService_GetUserPermissions(t *testing.T) { // Setup ac := setupTestEnv(t) - ac.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true} + ac.features = featuremgmt.WithToggles("accesscontrol") registration.Role.Permissions = []accesscontrol.Permission{tt.rawPerm} err := ac.DeclareFixedRoles(registration) @@ -638,7 +627,6 @@ func TestOSSAccessControlService_Evaluate(t *testing.T) { // Setup ac := setupTestEnv(t) - ac.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true} ac.RegisterAttributeScopeResolver("users:login:", userLoginScopeSolver) registration.Role.Permissions = []accesscontrol.Permission{tt.rawPerm} diff --git a/pkg/services/featuremgmt/features.go b/pkg/services/featuremgmt/features.go new file mode 100644 index 00000000000..5036eaabb40 --- /dev/null +++ b/pkg/services/featuremgmt/features.go @@ -0,0 +1,95 @@ +package featuremgmt + +import ( + "bytes" + "encoding/json" +) + +// FeatureToggleState indicates the quality level +type FeatureToggleState int + +const ( + // FeatureStateUnknown indicates that no state is specified + FeatureStateUnknown FeatureToggleState = iota + + // FeatureStateAlpha the feature is in active development and may change at any time + FeatureStateAlpha + + // FeatureStateBeta the feature is still in development, but settings will have migrations + FeatureStateBeta + + // FeatureStateStable this is a stable feature + FeatureStateStable + + // FeatureStateDeprecated the feature will be removed in the future + FeatureStateDeprecated +) + +func (s FeatureToggleState) String() string { + switch s { + case FeatureStateAlpha: + return "alpha" + case FeatureStateBeta: + return "beta" + case FeatureStateStable: + return "stable" + case FeatureStateDeprecated: + return "deprecated" + case FeatureStateUnknown: + } + return "unknown" +} + +// MarshalJSON marshals the enum as a quoted json string +func (s FeatureToggleState) MarshalJSON() ([]byte, error) { + buffer := bytes.NewBufferString(`"`) + buffer.WriteString(s.String()) + buffer.WriteString(`"`) + return buffer.Bytes(), nil +} + +// UnmarshalJSON unmarshals a quoted json string to the enum value +func (s *FeatureToggleState) UnmarshalJSON(b []byte) error { + var j string + err := json.Unmarshal(b, &j) + if err != nil { + return err + } + + switch j { + case "alpha": + *s = FeatureStateAlpha + + case "beta": + *s = FeatureStateBeta + + case "stable": + *s = FeatureStateStable + + case "deprecated": + *s = FeatureStateDeprecated + + default: + *s = FeatureStateUnknown + } + return nil +} + +type FeatureFlag struct { + Name string `json:"name" yaml:"name"` // Unique name + Description string `json:"description"` + State FeatureToggleState `json:"state,omitempty"` + DocsURL string `json:"docsURL,omitempty"` + + // CEL-GO expression. Using the value "true" will mean this is on by default + Expression string `json:"expression,omitempty"` + + // Special behavior flags + RequiresDevMode bool `json:"requiresDevMode,omitempty"` // can not be enabled in production + RequiresRestart bool `json:"requiresRestart,omitempty"` // The server must be initialized with the value + RequiresLicense bool `json:"requiresLicense,omitempty"` // Must be enabled in the license + FrontendOnly bool `json:"frontend,omitempty"` // change is only seen in the frontend + + // Internal properties + // expr string `json:-` +} diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go new file mode 100644 index 00000000000..997ad73ae02 --- /dev/null +++ b/pkg/services/featuremgmt/manager.go @@ -0,0 +1,195 @@ +package featuremgmt + +import ( + "context" + "fmt" + "reflect" + + "github.com/grafana/grafana/pkg/infra/log" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/models" +) + +type FeatureManager struct { + isDevMod bool + licensing models.Licensing + flags map[string]*FeatureFlag + enabled map[string]bool // only the "on" values + toggles *FeatureToggles + config string // path to config file + vars map[string]interface{} + log log.Logger +} + +// This will merge the flags with the current configuration +func (fm *FeatureManager) registerFlags(flags ...FeatureFlag) { + for idx, add := range flags { + if add.Name == "" { + continue // skip it with warning? + } + flag, ok := fm.flags[add.Name] + if !ok { + fm.flags[add.Name] = &flags[idx] + continue + } + + // Selectively update properties + if add.Description != "" { + flag.Description = add.Description + } + if add.DocsURL != "" { + flag.DocsURL = add.DocsURL + } + if add.Expression != "" { + flag.Expression = add.Expression + } + + // The most recently defined state + if add.State != FeatureStateUnknown { + flag.State = add.State + } + + // Only gets more restrictive + if add.RequiresDevMode { + flag.RequiresDevMode = true + } + + if add.RequiresLicense { + flag.RequiresLicense = true + } + + if add.RequiresRestart { + flag.RequiresRestart = true + } + } + + // This will evaluate all flags + fm.update() +} + +func (fm *FeatureManager) evaluate(ff *FeatureFlag) bool { + if ff.RequiresDevMode && !fm.isDevMod { + return false + } + + if ff.RequiresLicense && (fm.licensing == nil || !fm.licensing.FeatureEnabled(ff.Name)) { + return false + } + + // TODO: CEL - expression + return ff.Expression == "true" +} + +// Update +func (fm *FeatureManager) update() { + enabled := make(map[string]bool) + for _, flag := range fm.flags { + val := fm.evaluate(flag) + + // Update the registry + track := 0.0 + if val { + track = 1 + enabled[flag.Name] = true + } + + // Register value with prometheus metric + featureToggleInfo.WithLabelValues(flag.Name).Set(track) + } + fm.enabled = enabled +} + +// Run is called by background services +func (fm *FeatureManager) readFile() error { + if fm.config == "" { + return nil // not configured + } + + cfg, err := readConfigFile(fm.config) + if err != nil { + return err + } + + fm.registerFlags(cfg.Flags...) + fm.vars = cfg.Vars + + return nil +} + +// IsEnabled checks if a feature is enabled +func (fm *FeatureManager) IsEnabled(flag string) bool { + return fm.enabled[flag] +} + +// GetEnabled returns a map contaning only the features that are enabled +func (fm *FeatureManager) GetEnabled(ctx context.Context) map[string]bool { + enabled := make(map[string]bool, len(fm.enabled)) + for key, val := range fm.enabled { + if val { + enabled[key] = true + } + } + return enabled +} + +// Toggles returns FeatureToggles. +func (fm *FeatureManager) Toggles() *FeatureToggles { + if fm.toggles == nil { + fm.toggles = &FeatureToggles{manager: fm} + } + return fm.toggles +} + +// GetFlags returns all flag definitions +func (fm *FeatureManager) GetFlags() []FeatureFlag { + v := make([]FeatureFlag, 0, len(fm.flags)) + for _, value := range fm.flags { + v = append(v, *value) + } + return v +} + +func (fm *FeatureManager) HandleGetSettings(c *models.ReqContext) { + res := make(map[string]interface{}, 3) + res["enabled"] = fm.GetEnabled(c.Req.Context()) + + vv := make([]*FeatureFlag, 0, len(fm.flags)) + for _, v := range fm.flags { + vv = append(vv, v) + } + + res["info"] = vv + + response.JSON(200, res).WriteTo(c) +} + +// WithFeatures is used to define feature toggles for testing. +// The arguments are a list of strings that are optionally followed by a boolean value +func WithFeatures(spec ...interface{}) *FeatureManager { + count := len(spec) + enabled := make(map[string]bool, count) + + idx := 0 + for idx < count { + key := fmt.Sprintf("%v", spec[idx]) + val := true + idx++ + if idx < count && reflect.TypeOf(spec[idx]).Kind() == reflect.Bool { + val = spec[idx].(bool) + idx++ + } + + if val { + enabled[key] = true + } + } + + return &FeatureManager{enabled: enabled} +} + +func WithToggles(spec ...interface{}) *FeatureToggles { + return &FeatureToggles{ + manager: WithFeatures(spec...), + } +} diff --git a/pkg/services/featuremgmt/manager_test.go b/pkg/services/featuremgmt/manager_test.go new file mode 100644 index 00000000000..68c31daf9a9 --- /dev/null +++ b/pkg/services/featuremgmt/manager_test.go @@ -0,0 +1,77 @@ +package featuremgmt + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFeatureManager(t *testing.T) { + t.Run("check testing stubs", func(t *testing.T) { + ft := WithFeatures("a", "b", "c") + require.True(t, ft.IsEnabled("a")) + require.True(t, ft.IsEnabled("b")) + require.True(t, ft.IsEnabled("c")) + require.False(t, ft.IsEnabled("d")) + + require.Equal(t, map[string]bool{"a": true, "b": true, "c": true}, ft.GetEnabled(context.Background())) + + // Explicit values + ft = WithFeatures("a", true, "b", false) + require.True(t, ft.IsEnabled("a")) + require.False(t, ft.IsEnabled("b")) + require.Equal(t, map[string]bool{"a": true}, ft.GetEnabled(context.Background())) + }) + + t.Run("check license validation", func(t *testing.T) { + ft := FeatureManager{ + flags: map[string]*FeatureFlag{}, + } + ft.registerFlags(FeatureFlag{ + Name: "a", + RequiresLicense: true, + RequiresDevMode: true, + Expression: "true", + }, FeatureFlag{ + Name: "b", + Expression: "true", + }) + require.False(t, ft.IsEnabled("a")) + require.True(t, ft.IsEnabled("b")) + require.False(t, ft.IsEnabled("c")) // uknown flag + + // Try changing "requires license" + ft.registerFlags(FeatureFlag{ + Name: "a", + RequiresLicense: false, // shuld still require license! + }, FeatureFlag{ + Name: "b", + RequiresLicense: true, // expression is still "true" + }) + require.False(t, ft.IsEnabled("a")) + require.False(t, ft.IsEnabled("b")) + require.False(t, ft.IsEnabled("c")) + }) + + t.Run("check description and docs configs", func(t *testing.T) { + ft := FeatureManager{ + flags: map[string]*FeatureFlag{}, + } + ft.registerFlags(FeatureFlag{ + Name: "a", + Description: "first", + }, FeatureFlag{ + Name: "a", + Description: "second", + }, FeatureFlag{ + Name: "a", + DocsURL: "http://something", + }, FeatureFlag{ + Name: "a", + }) + flag := ft.flags["a"] + require.Equal(t, "second", flag.Description) + require.Equal(t, "http://something", flag.DocsURL) + }) +} diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go new file mode 100644 index 00000000000..a92362d294d --- /dev/null +++ b/pkg/services/featuremgmt/registry.go @@ -0,0 +1,163 @@ +package featuremgmt + +import "github.com/grafana/grafana/pkg/services/secrets" + +var ( + FLAG_database_metrics = "database_metrics" + FLAG_live_config = "live-config" + FLAG_recordedQueries = "recordedQueries" + + // Register each toggle here + standardFeatureFlags = []FeatureFlag{ + { + Name: FLAG_recordedQueries, + Description: "Supports saving queries that can be scraped by prometheus", + State: FeatureStateBeta, + RequiresLicense: true, + }, + { + Name: "teamsync", + Description: "Team sync lets you set up synchronization between your auth providers teams and teams in Grafana", + State: FeatureStateStable, + DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/team-sync/", + RequiresLicense: true, + }, + { + Name: "ldapsync", + Description: "Enhanced LDAP integration", + State: FeatureStateStable, + DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/enhanced_ldap/", + RequiresLicense: true, + }, + { + Name: "caching", + Description: "Temporarily store data source query results.", + State: FeatureStateStable, + DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/query-caching/", + RequiresLicense: true, + }, + { + Name: "dspermissions", + Description: "Data source permissions", + State: FeatureStateStable, + DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/datasource_permissions/", + RequiresLicense: true, + }, + { + Name: "analytics", + Description: "Analytics", + State: FeatureStateStable, + RequiresLicense: true, + }, + { + Name: "enterprise.plugins", + Description: "Enterprise plugins", + State: FeatureStateStable, + DocsURL: "https://grafana.com/grafana/plugins/?enterprise=1", + RequiresLicense: true, + }, + { + Name: "trimDefaults", + Description: "Use cue schema to remove values that will be applied automatically", + State: FeatureStateBeta, + }, + { + Name: secrets.EnvelopeEncryptionFeatureToggle, + Description: "encrypt secrets", + State: FeatureStateBeta, + }, + + { + Name: "httpclientprovider_azure_auth", + State: FeatureStateBeta, + }, + { + Name: "service-accounts", + Description: "support service accounts", + State: FeatureStateBeta, + RequiresLicense: true, + }, + + { + Name: FLAG_database_metrics, + Description: "Add prometheus metrics for database tables", + State: FeatureStateStable, + }, + { + Name: "dashboardPreviews", + Description: "Create and show thumbnails for dashboard search results", + State: FeatureStateAlpha, + }, + { + Name: FLAG_live_config, + Description: "Save grafana live configuration in SQL tables", + State: FeatureStateAlpha, + }, + { + Name: "live-pipeline", + Description: "enable a generic live processing pipeline", + State: FeatureStateAlpha, + }, + { + Name: "live-service-web-worker", + Description: "This will use a webworker thread to processes events rather than the main thread", + State: FeatureStateAlpha, + FrontendOnly: true, + }, + { + Name: "queryOverLive", + Description: "Use grafana live websocket to execute backend queries", + State: FeatureStateAlpha, + FrontendOnly: true, + }, + { + Name: "tempoSearch", + Description: "Enable searching in tempo datasources", + State: FeatureStateBeta, + FrontendOnly: true, + }, + { + Name: "tempoBackendSearch", + Description: "Use backend for tempo search", + State: FeatureStateBeta, + }, + { + Name: "tempoServiceGraph", + Description: "show service", + State: FeatureStateBeta, + FrontendOnly: true, + }, + { + Name: "fullRangeLogsVolume", + Description: "Show full range logs volume in expore", + State: FeatureStateBeta, + FrontendOnly: true, + }, + { + Name: "accesscontrol", + Description: "Support robust access control", + State: FeatureStateBeta, + RequiresLicense: true, + }, + { + Name: "prometheus_azure_auth", + Description: "Use azure authentication for prometheus datasource", + State: FeatureStateBeta, + }, + { + Name: "newNavigation", + Description: "Try the next gen naviation model", + State: FeatureStateAlpha, + }, + { + Name: "showFeatureFlagsInUI", + Description: "Show feature flags in the settings UI", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, + { + Name: "disable_http_request_histogram", + State: FeatureStateAlpha, + }, + } +) diff --git a/pkg/services/featuremgmt/service.go b/pkg/services/featuremgmt/service.go new file mode 100644 index 00000000000..4ff1033f826 --- /dev/null +++ b/pkg/services/featuremgmt/service.go @@ -0,0 +1,78 @@ +package featuremgmt + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/grafana/grafana/pkg/infra/log" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // The values are updated each time + featureToggleInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "feature_toggles_info", + Help: "info metric that exposes what feature toggles are enabled or not", + Namespace: "grafana", + }, []string{"name"}) +) + +func ProvideManagerService(cfg *setting.Cfg, licensing models.Licensing) (*FeatureManager, error) { + mgmt := &FeatureManager{ + isDevMod: setting.Env != setting.Prod, + licensing: licensing, + flags: make(map[string]*FeatureFlag, 30), + enabled: make(map[string]bool), + log: log.New("featuremgmt"), + } + + // Register the standard flags + mgmt.registerFlags(standardFeatureFlags...) + + // Load the flags from `custom.ini` files + flags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) + if err != nil { + return mgmt, err + } + for key, val := range flags { + flag, ok := mgmt.flags[key] + if !ok { + flag = &FeatureFlag{ + Name: key, + State: FeatureStateUnknown, + } + mgmt.flags[key] = flag + } + flag.Expression = fmt.Sprintf("%t", val) // true | false + } + + // Load config settings + configfile := filepath.Join(cfg.HomePath, "conf", "features.yaml") + if _, err := os.Stat(configfile); err == nil { + mgmt.log.Info("[experimental] loading features from config file", "path", configfile) + mgmt.config = configfile + err = mgmt.readFile() + if err != nil { + return mgmt, err + } + } + + // update the values + mgmt.update() + + // Minimum approach to avoid circular dependency + cfg.IsFeatureToggleEnabled = mgmt.IsEnabled + return mgmt, nil +} + +// ProvideToggles allows read-only access to the feature state +func ProvideToggles(mgmt *FeatureManager) *FeatureToggles { + return &FeatureToggles{ + manager: mgmt, + } +} diff --git a/pkg/services/featuremgmt/settings.go b/pkg/services/featuremgmt/settings.go new file mode 100644 index 00000000000..dc2137bf567 --- /dev/null +++ b/pkg/services/featuremgmt/settings.go @@ -0,0 +1,34 @@ +package featuremgmt + +import ( + "io/ioutil" + + "gopkg.in/yaml.v2" +) + +type configBody struct { + // define variables that can be used in expressions + Vars map[string]interface{} `yaml:"vars"` + + // Define and override feature flag properties + Flags []FeatureFlag `yaml:"flags"` + + // keep track of where the fie was loaded from + filename string +} + +// will read a single configfile +func readConfigFile(filename string) (*configBody, error) { + cfg := &configBody{} + + // Can ignore gosec G304 because the file path is forced within config subfolder + //nolint:gosec + yamlFile, err := ioutil.ReadFile(filename) + if err != nil { + return cfg, err + } + + err = yaml.Unmarshal(yamlFile, cfg) + cfg.filename = filename + return cfg, err +} diff --git a/pkg/services/featuremgmt/settings_test.go b/pkg/services/featuremgmt/settings_test.go new file mode 100644 index 00000000000..58683ad5971 --- /dev/null +++ b/pkg/services/featuremgmt/settings_test.go @@ -0,0 +1,25 @@ +package featuremgmt + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestReadingFeatureSettings(t *testing.T) { + config, err := readConfigFile("testdata/features.yaml") + require.NoError(t, err, "No error when reading feature configs") + + assert.Equal(t, map[string]interface{}{ + "level": "free", + "stack": "something", + "valA": "value from features.yaml", + }, config.Vars) + + out, err := yaml.Marshal(config) + require.NoError(t, err) + fmt.Printf("%s", string(out)) +} diff --git a/pkg/services/featuremgmt/testdata/features.yaml b/pkg/services/featuremgmt/testdata/features.yaml new file mode 100644 index 00000000000..dd737494580 --- /dev/null +++ b/pkg/services/featuremgmt/testdata/features.yaml @@ -0,0 +1,33 @@ +include: + - included.yaml # not yet supported + +vars: + stack: something + level: free + valA: value from features.yaml + +flags: + - name: feature1 + description: feature1 + expression: "false" + + - name: feature3 + description: feature3 + expression: "true" + + - name: feature3 + description: feature3 + expression: env.level == 'free' + + - name: displaySwedishTheme + description: enable swedish background theme + expression: | + // restrict to users allowing swedish language + req.locale.contains("sv") + - name: displayFrenchFlag + description: sho background theme + expression: | + // only admins + user.id == 1 + // show to users allowing french language + && req.locale.contains("fr") \ No newline at end of file diff --git a/pkg/services/featuremgmt/testdata/included.yaml b/pkg/services/featuremgmt/testdata/included.yaml new file mode 100644 index 00000000000..322b5c7972f --- /dev/null +++ b/pkg/services/featuremgmt/testdata/included.yaml @@ -0,0 +1,13 @@ +include: + - features.yaml # make sure we avoid recusion! + +# variables that can be used in expressions +vars: + stack: something + deep: 1 + valA: value from included.yaml + +flags: + - name: featureFromIncludedFile + description: an inlcuded file + expression: invalid expression string here diff --git a/pkg/services/featuremgmt/toggles.go b/pkg/services/featuremgmt/toggles.go new file mode 100644 index 00000000000..31daaca070e --- /dev/null +++ b/pkg/services/featuremgmt/toggles.go @@ -0,0 +1,10 @@ +package featuremgmt + +type FeatureToggles struct { + manager *FeatureManager +} + +// IsEnabled checks if a feature is enabled +func (ft *FeatureToggles) IsEnabled(flag string) bool { + return ft.manager.IsEnabled(flag) +} diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go new file mode 100644 index 00000000000..b72412abcdb --- /dev/null +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -0,0 +1,157 @@ +// NOTE: This file is autogenerated + +package featuremgmt + +// IsRecordedQueriesEnabled checks for the flag: recordedQueries +// Supports saving queries that can be scraped by prometheus +func (ft *FeatureToggles) IsRecordedQueriesEnabled() bool { + return ft.manager.IsEnabled("recordedQueries") +} + +// IsTeamsyncEnabled checks for the flag: teamsync +// Team sync lets you set up synchronization between your auth providers teams and teams in Grafana +func (ft *FeatureToggles) IsTeamsyncEnabled() bool { + return ft.manager.IsEnabled("teamsync") +} + +// IsLdapsyncEnabled checks for the flag: ldapsync +// Enhanced LDAP integration +func (ft *FeatureToggles) IsLdapsyncEnabled() bool { + return ft.manager.IsEnabled("ldapsync") +} + +// IsCachingEnabled checks for the flag: caching +// Temporarily store data source query results. +func (ft *FeatureToggles) IsCachingEnabled() bool { + return ft.manager.IsEnabled("caching") +} + +// IsDspermissionsEnabled checks for the flag: dspermissions +// Data source permissions +func (ft *FeatureToggles) IsDspermissionsEnabled() bool { + return ft.manager.IsEnabled("dspermissions") +} + +// IsAnalyticsEnabled checks for the flag: analytics +// Analytics +func (ft *FeatureToggles) IsAnalyticsEnabled() bool { + return ft.manager.IsEnabled("analytics") +} + +// IsEnterprisePluginsEnabled checks for the flag: enterprise.plugins +// Enterprise plugins +func (ft *FeatureToggles) IsEnterprisePluginsEnabled() bool { + return ft.manager.IsEnabled("enterprise.plugins") +} + +// IsTrimDefaultsEnabled checks for the flag: trimDefaults +// Use cue schema to remove values that will be applied automatically +func (ft *FeatureToggles) IsTrimDefaultsEnabled() bool { + return ft.manager.IsEnabled("trimDefaults") +} + +// IsEnvelopeEncryptionEnabled checks for the flag: envelopeEncryption +// encrypt secrets +func (ft *FeatureToggles) IsEnvelopeEncryptionEnabled() bool { + return ft.manager.IsEnabled("envelopeEncryption") +} + +// IsHttpclientproviderAzureAuthEnabled checks for the flag: httpclientprovider_azure_auth +func (ft *FeatureToggles) IsHttpclientproviderAzureAuthEnabled() bool { + return ft.manager.IsEnabled("httpclientprovider_azure_auth") +} + +// IsServiceAccountsEnabled checks for the flag: service-accounts +// support service accounts +func (ft *FeatureToggles) IsServiceAccountsEnabled() bool { + return ft.manager.IsEnabled("service-accounts") +} + +// IsDatabaseMetricsEnabled checks for the flag: database_metrics +// Add prometheus metrics for database tables +func (ft *FeatureToggles) IsDatabaseMetricsEnabled() bool { + return ft.manager.IsEnabled("database_metrics") +} + +// IsDashboardPreviewsEnabled checks for the flag: dashboardPreviews +// Create and show thumbnails for dashboard search results +func (ft *FeatureToggles) IsDashboardPreviewsEnabled() bool { + return ft.manager.IsEnabled("dashboardPreviews") +} + +// IsLiveConfigEnabled checks for the flag: live-config +// Save grafana live configuration in SQL tables +func (ft *FeatureToggles) IsLiveConfigEnabled() bool { + return ft.manager.IsEnabled("live-config") +} + +// IsLivePipelineEnabled checks for the flag: live-pipeline +// enable a generic live processing pipeline +func (ft *FeatureToggles) IsLivePipelineEnabled() bool { + return ft.manager.IsEnabled("live-pipeline") +} + +// IsLiveServiceWebWorkerEnabled checks for the flag: live-service-web-worker +// This will use a webworker thread to processes events rather than the main thread +func (ft *FeatureToggles) IsLiveServiceWebWorkerEnabled() bool { + return ft.manager.IsEnabled("live-service-web-worker") +} + +// IsQueryOverLiveEnabled checks for the flag: queryOverLive +// Use grafana live websocket to execute backend queries +func (ft *FeatureToggles) IsQueryOverLiveEnabled() bool { + return ft.manager.IsEnabled("queryOverLive") +} + +// IsTempoSearchEnabled checks for the flag: tempoSearch +// Enable searching in tempo datasources +func (ft *FeatureToggles) IsTempoSearchEnabled() bool { + return ft.manager.IsEnabled("tempoSearch") +} + +// IsTempoBackendSearchEnabled checks for the flag: tempoBackendSearch +// Use backend for tempo search +func (ft *FeatureToggles) IsTempoBackendSearchEnabled() bool { + return ft.manager.IsEnabled("tempoBackendSearch") +} + +// IsTempoServiceGraphEnabled checks for the flag: tempoServiceGraph +// show service +func (ft *FeatureToggles) IsTempoServiceGraphEnabled() bool { + return ft.manager.IsEnabled("tempoServiceGraph") +} + +// IsFullRangeLogsVolumeEnabled checks for the flag: fullRangeLogsVolume +// Show full range logs volume in expore +func (ft *FeatureToggles) IsFullRangeLogsVolumeEnabled() bool { + return ft.manager.IsEnabled("fullRangeLogsVolume") +} + +// IsAccesscontrolEnabled checks for the flag: accesscontrol +// Support robust access control +func (ft *FeatureToggles) IsAccesscontrolEnabled() bool { + return ft.manager.IsEnabled("accesscontrol") +} + +// IsPrometheusAzureAuthEnabled checks for the flag: prometheus_azure_auth +// Use azure authentication for prometheus datasource +func (ft *FeatureToggles) IsPrometheusAzureAuthEnabled() bool { + return ft.manager.IsEnabled("prometheus_azure_auth") +} + +// IsNewNavigationEnabled checks for the flag: newNavigation +// Try the next gen naviation model +func (ft *FeatureToggles) IsNewNavigationEnabled() bool { + return ft.manager.IsEnabled("newNavigation") +} + +// IsShowFeatureFlagsInUIEnabled checks for the flag: showFeatureFlagsInUI +// Show feature flags in the settings UI +func (ft *FeatureToggles) IsShowFeatureFlagsInUIEnabled() bool { + return ft.manager.IsEnabled("showFeatureFlagsInUI") +} + +// IsDisableHttpRequestHistogramEnabled checks for the flag: disable_http_request_histogram +func (ft *FeatureToggles) IsDisableHttpRequestHistogramEnabled() bool { + return ft.manager.IsEnabled("disable_http_request_histogram") +} diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go new file mode 100644 index 00000000000..dd27c434451 --- /dev/null +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -0,0 +1,140 @@ +package featuremgmt + +import ( + "bytes" + "fmt" + "html/template" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + "unicode" + + "github.com/google/go-cmp/cmp" +) + +func TestFeatureToggleFiles(t *testing.T) { + // Typescript files + verifyAndGenerateFile(t, + "../../../packages/grafana-data/src/types/featureToggles.gen.ts", + generateTypeScript(), + ) + + // Golang files + verifyAndGenerateFile(t, + "toggles_gen.go", + generateRegistry(t), + ) +} + +func verifyAndGenerateFile(t *testing.T, fpath string, gen string) { + // nolint:gosec + // We can ignore the gosec G304 warning since this is a test and the function is only called explicitly above + body, err := ioutil.ReadFile(fpath) + if err == nil { + if diff := cmp.Diff(gen, string(body)); diff != "" { + str := fmt.Sprintf("body mismatch (-want +got):\n%s\n", diff) + err = fmt.Errorf(str) + } + } + + if err != nil { + e2 := os.WriteFile(fpath, []byte(gen), 0644) + if e2 != nil { + t.Errorf("error writing file: %s", e2.Error()) + } + abs, _ := filepath.Abs(fpath) + t.Errorf("feature toggle do not match: %s (%s)", err.Error(), abs) + t.Fail() + } +} + +func generateTypeScript() string { + buf := `// NOTE: This file was auto generated. DO NOT EDIT DIRECTLY! +// To change feature flags, edit: +// pkg/services/featuremgmt/registry.go +// Then run tests in: +// pkg/services/featuremgmt/toggles_gen_test.go + +/** + * Describes available feature toggles in Grafana. These can be configured via + * conf/custom.ini to enable features under development or not yet available in + * stable version. + * + * Only enabled values will be returned in this interface + * + * @public + */ +export interface FeatureToggles { + [name: string]: boolean | undefined; // support any string value + +` + for _, flag := range standardFeatureFlags { + buf += " " + getTypeScriptKey(flag.Name) + "?: boolean;\n" + } + + buf += "}\n" + return buf +} + +func getTypeScriptKey(key string) string { + if strings.Contains(key, "-") || strings.Contains(key, ".") { + return "['" + key + "']" + } + return key +} + +func isLetterOrNumber(c rune) bool { + return !unicode.IsLetter(c) && !unicode.IsNumber(c) +} + +func asCamelCase(key string) string { + parts := strings.FieldsFunc(key, isLetterOrNumber) + for idx, part := range parts { + parts[idx] = strings.Title(part) + } + return strings.Join(parts, "") +} + +func generateRegistry(t *testing.T) string { + tmpl, err := template.New("fn").Parse(` +// Is{{.CamleCase}}Enabled checks for the flag: {{.Flag.Name}}{{.Ext}} +func (ft *FeatureToggles) Is{{.CamleCase}}Enabled() bool { + return ft.manager.IsEnabled("{{.Flag.Name}}") +} +`) + if err != nil { + t.Fatal("error reading template", "error", err.Error()) + return "" + } + + data := struct { + CamleCase string + Flag FeatureFlag + Ext string + }{ + CamleCase: "?", + } + + var buff bytes.Buffer + + buff.WriteString(`// NOTE: This file is autogenerated + +package featuremgmt +`) + + for _, flag := range standardFeatureFlags { + data.CamleCase = asCamelCase(flag.Name) + data.Flag = flag + data.Ext = "" + + if flag.Description != "" { + data.Ext += "\n// " + flag.Description + } + + _ = tmpl.Execute(&buff, data) + } + + return buff.String() +} diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 70dfbbda0b3..e7093474885 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -15,6 +15,7 @@ import ( jsoniter "github.com/json-iterator/go" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/query" "github.com/centrifugal/centrifuge" @@ -67,9 +68,10 @@ type CoreGrafanaScope struct { func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, routeRegister routing.RouteRegister, pluginStore plugins.Store, cacheService *localcache.CacheService, dataSourceCache datasources.CacheService, sqlStore *sqlstore.SQLStore, secretsService secrets.Service, - usageStatsService usagestats.Service, queryDataService *query.Service) (*GrafanaLive, error) { + usageStatsService usagestats.Service, queryDataService *query.Service, toggles *featuremgmt.FeatureToggles) (*GrafanaLive, error) { g := &GrafanaLive{ Cfg: cfg, + Features: toggles, PluginContextProvider: plugCtxProvider, RouteRegister: routeRegister, pluginStore: pluginStore, @@ -174,7 +176,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r } g.ManagedStreamRunner = managedStreamRunner - if enabled := g.Cfg.FeatureToggles["live-pipeline"]; enabled { + if g.Features.IsLivePipelineEnabled() { var builder pipeline.RuleBuilder if os.Getenv("GF_LIVE_DEV_BUILDER") != "" { builder = &pipeline.DevRuleBuilder{ @@ -391,6 +393,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r type GrafanaLive struct { PluginContextProvider *plugincontext.Provider Cfg *setting.Cfg + Features *featuremgmt.FeatureToggles RouteRegister routing.RouteRegister CacheService *localcache.CacheService DataSourceCache datasources.CacheService diff --git a/pkg/services/schemaloader/schemaloader.go b/pkg/services/schemaloader/schemaloader.go index e10a2661dbc..2ab666022a2 100644 --- a/pkg/services/schemaloader/schemaloader.go +++ b/pkg/services/schemaloader/schemaloader.go @@ -8,9 +8,9 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/schema" "github.com/grafana/grafana/pkg/schema/load" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" ) const ServiceName = "SchemaLoader" @@ -26,13 +26,13 @@ type RenderUser struct { OrgRole string } -func ProvideService(cfg *setting.Cfg) (*SchemaLoaderService, error) { +func ProvideService(features *featuremgmt.FeatureToggles) (*SchemaLoaderService, error) { dashFam, err := load.BaseDashboardFamily(baseLoadPath) if err != nil { return nil, fmt.Errorf("failed to load dashboard cue schema from path %q: %w", baseLoadPath, err) } s := &SchemaLoaderService{ - Cfg: cfg, + features: features, DashFamily: dashFam, log: log.New("schemaloader"), } @@ -42,14 +42,14 @@ func ProvideService(cfg *setting.Cfg) (*SchemaLoaderService, error) { type SchemaLoaderService struct { log log.Logger DashFamily schema.VersionedCueSchema - Cfg *setting.Cfg + features *featuremgmt.FeatureToggles } func (rs *SchemaLoaderService) IsDisabled() bool { - if rs.Cfg == nil { + if rs.features == nil { return true } - return !rs.Cfg.IsTrimDefaultsEnabled() + return !rs.features.IsTrimDefaultsEnabled() } func (rs *SchemaLoaderService) DashboardApplyDefaults(input *simplejson.Json) (*simplejson.Json, error) { diff --git a/pkg/services/secrets/manager/helpers.go b/pkg/services/secrets/manager/helpers.go index 0419370d60e..340de00357e 100644 --- a/pkg/services/secrets/manager/helpers.go +++ b/pkg/services/secrets/manager/helpers.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" @@ -23,10 +24,15 @@ func SetupTestService(tb testing.TB, store secrets.Store) *SecretsService { [security] secret_key = ` + defaultKey)) require.NoError(tb, err) + + features := featuremgmt.WithToggles("envelopeEncryption") + cfg := &setting.Cfg{Raw: raw} - cfg.FeatureToggles = map[string]bool{secrets.EnvelopeEncryptionFeatureToggle: true} + cfg.IsFeatureToggleEnabled = features.IsEnabled + settings := &setting.OSSImpl{Cfg: cfg} - assert.True(tb, settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle)) + assert.True(tb, settings.IsFeatureToggleEnabled("envelopeEncryption")) + assert.True(tb, features.IsEnvelopeEncryptionEnabled()) encryption := ossencryption.ProvideService() secretsService, err := ProvideSecretsService( diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index df442b49d79..c1152617678 100644 --- a/pkg/services/secrets/manager/manager_test.go +++ b/pkg/services/secrets/manager/manager_test.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption/ossencryption" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/kmsproviders/osskmsproviders" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" @@ -180,8 +181,8 @@ func TestSecretsService_UseCurrentProvider(t *testing.T) { providerID := secrets.ProviderID("fakeProvider.v1") settings := &setting.OSSImpl{ Cfg: &setting.Cfg{ - Raw: raw, - FeatureToggles: map[string]bool{secrets.EnvelopeEncryptionFeatureToggle: true}, + Raw: raw, + IsFeatureToggleEnabled: featuremgmt.WithToggles(secrets.EnvelopeEncryptionFeatureToggle).IsEnabled, }, } encr := ossencryption.ProvideService() diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index edc76f417fd..28b567d5d88 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -13,8 +13,8 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -40,9 +40,9 @@ func NewServiceAccountsAPI( } func (api *ServiceAccountsAPI) RegisterAPIEndpoints( - cfg *setting.Cfg, + features *featuremgmt.FeatureToggles, ) { - if !cfg.FeatureToggles["service-accounts"] { + if !features.IsServiceAccountsEnabled() { return } diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 0586ab4a900..da63d19b856 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -13,10 +13,10 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/stretchr/testify/require" @@ -97,7 +97,7 @@ func serviceAccountRequestScenario(t *testing.T, httpMethod string, endpoint str func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock, sqlStore *sqlstore.SQLStore) *web.Mux { a := NewServiceAccountsAPI(svc, acmock, routerRegister, database.NewServiceAccountsStore(sqlStore)) - a.RegisterAPIEndpoints(&setting.Cfg{FeatureToggles: map[string]bool{"service-accounts": true}}) + a.RegisterAPIEndpoints(featuremgmt.WithToggles("service-accounts")) m := web.New() signedUser := &models.SignedInUser{ diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 8f7a440d012..23a1e13e2a1 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -7,11 +7,11 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/api" "github.com/grafana/grafana/pkg/services/serviceaccounts/database" "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/setting" ) var ( @@ -19,21 +19,21 @@ var ( ) type ServiceAccountsService struct { - store serviceaccounts.Store - cfg *setting.Cfg - log log.Logger + store serviceaccounts.Store + features *featuremgmt.FeatureToggles + log log.Logger } func ProvideServiceAccountsService( - cfg *setting.Cfg, + features *featuremgmt.FeatureToggles, store *sqlstore.SQLStore, ac accesscontrol.AccessControl, routeRegister routing.RouteRegister, ) (*ServiceAccountsService, error) { s := &ServiceAccountsService{ - cfg: cfg, - store: database.NewServiceAccountsStore(store), - log: log.New("serviceaccounts"), + features: features, + store: database.NewServiceAccountsStore(store), + log: log.New("serviceaccounts"), } if err := RegisterRoles(ac); err != nil { @@ -41,13 +41,13 @@ func ProvideServiceAccountsService( } serviceaccountsAPI := api.NewServiceAccountsAPI(s, ac, routeRegister, s.store) - serviceaccountsAPI.RegisterAPIEndpoints(cfg) + serviceaccountsAPI.RegisterAPIEndpoints(features) return s, nil } func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saForm *serviceaccounts.CreateServiceaccountForm) (*models.User, error) { - if !sa.cfg.FeatureToggles["service-accounts"] { + if !sa.features.IsServiceAccountsEnabled() { sa.log.Debug(ServiceAccountFeatureToggleNotFound) return nil, nil } @@ -55,7 +55,7 @@ func (sa *ServiceAccountsService) CreateServiceAccount(ctx context.Context, saFo } func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error { - if !sa.cfg.FeatureToggles["service-accounts"] { + if !sa.features.IsServiceAccountsEnabled() { sa.log.Debug(ServiceAccountFeatureToggleNotFound) return nil } diff --git a/pkg/services/serviceaccounts/manager/service_test.go b/pkg/services/serviceaccounts/manager/service_test.go index 460e5d61e1d..96fe13d127b 100644 --- a/pkg/services/serviceaccounts/manager/service_test.go +++ b/pkg/services/serviceaccounts/manager/service_test.go @@ -5,31 +5,29 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" - "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProvideServiceAccount_DeleteServiceAccount(t *testing.T) { t.Run("feature toggle present, should call store function", func(t *testing.T) { - cfg := setting.NewCfg() storeMock := &tests.ServiceAccountsStoreMock{Calls: tests.Calls{}} - cfg.FeatureToggles = map[string]bool{"service-accounts": true} - svc := ServiceAccountsService{cfg: cfg, store: storeMock} + svc := ServiceAccountsService{ + features: featuremgmt.WithToggles("service-accounts", true), + store: storeMock} err := svc.DeleteServiceAccount(context.Background(), 1, 1) require.NoError(t, err) assert.Len(t, storeMock.Calls.DeleteServiceAccount, 1) }) t.Run("no feature toggle present, should not call store function", func(t *testing.T) { - cfg := setting.NewCfg() svcMock := &tests.ServiceAccountsStoreMock{Calls: tests.Calls{}} - cfg.FeatureToggles = map[string]bool{"service-accounts": false} svc := ServiceAccountsService{ - cfg: cfg, - store: svcMock, - log: log.New("serviceaccounts-manager-test"), + features: featuremgmt.WithToggles("service-accounts", false), + store: svcMock, + log: log.New("serviceaccounts-manager-test"), } err := svc.DeleteServiceAccount(context.Background(), 1, 1) require.NoError(t, err) diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index cb1d9b1d269..20873b4e5ff 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -3,6 +3,7 @@ package migrations import ( "os" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/accesscontrol" "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert" . "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -56,8 +57,8 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { ualert.AddTablesMigrations(mg) ualert.AddDashAlertMigration(mg) addLibraryElementsMigrations(mg) - if mg.Cfg != nil { - if mg.Cfg.IsLiveConfigEnabled() { + if mg.Cfg.IsFeatureToggleEnabled != nil { + if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FLAG_live_config) { addLiveChannelMigrations(mg) } } diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 83e29c2b7ca..a68150c0260 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -117,7 +117,7 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu // service accounts table in the modelling whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %t", x.Dialect().Quote("user"), query.IsServiceAccount)) - if ss.Cfg.FeatureToggles["accesscontrol"] { + if ss.Cfg.IsFeatureToggleEnabled("accesscontrol") { q, args, err := accesscontrol.Filter(ctx, ss.Dialect, "org_user.user_id", "users", "org.users:read", query.User) if err != nil { return err @@ -180,7 +180,7 @@ func (ss *SQLStore) SearchOrgUsers(ctx context.Context, query *models.SearchOrgU // service accounts table in the modelling whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %t", x.Dialect().Quote("user"), query.IsServiceAccount)) - if ss.Cfg.FeatureToggles["accesscontrol"] { + if ss.Cfg.IsFeatureToggleEnabled("accesscontrol") { q, args, err := accesscontrol.Filter(ctx, ss.Dialect, "org_user.user_id", "users", "org.users:read", query.User) if err != nil { return err diff --git a/pkg/services/sqlstore/org_users_test.go b/pkg/services/sqlstore/org_users_test.go index 216ce124bf7..cb8d32b05f8 100644 --- a/pkg/services/sqlstore/org_users_test.go +++ b/pkg/services/sqlstore/org_users_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) type getOrgUsersTestCase struct { @@ -61,7 +62,7 @@ func TestSQLStore_GetOrgUsers(t *testing.T) { } store := InitTestDB(t) - store.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true} + store.Cfg.IsFeatureToggleEnabled = featuremgmt.WithToggles("accesscontrol").IsEnabled seedOrgUsers(t, store, 10) for _, tt := range tests { @@ -127,7 +128,7 @@ func TestSQLStore_SearchOrgUsers(t *testing.T) { } store := InitTestDB(t) - store.Cfg.FeatureToggles = map[string]bool{"accesscontrol": true} + store.Cfg.IsFeatureToggleEnabled = featuremgmt.WithToggles("accesscontrol").IsEnabled seedOrgUsers(t, store, 10) for _, tt := range tests { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 668fcb89e93..dfceafe36ed 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrations" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" @@ -326,7 +327,7 @@ func (ss *SQLStore) initEngine(engine *xorm.Engine) error { return err } - if ss.Cfg.IsDatabaseMetricsEnabled() { + if ss.Cfg.IsFeatureToggleEnabled(featuremgmt.FLAG_database_metrics) { ss.dbCfg.Type = WrapDatabaseDriverWithHooks(ss.dbCfg.Type, ss.tracer) } @@ -492,6 +493,7 @@ func initTestDB(migration registry.DatabaseMigrator, opts ...InitTestDBOpt) (*SQ // set test db config cfg := setting.NewCfg() + cfg.IsFeatureToggleEnabled = func(key string) bool { return false } sec, err := cfg.Raw.NewSection("database") if err != nil { return nil, err diff --git a/pkg/services/thumbs/service.go b/pkg/services/thumbs/service.go index 6bd41dd44d8..c0bfbc11290 100644 --- a/pkg/services/thumbs/service.go +++ b/pkg/services/thumbs/service.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/rendering" @@ -39,8 +40,8 @@ type Service interface { CrawlerStatus(c *models.ReqContext) response.Response } -func ProvideService(cfg *setting.Cfg, renderService rendering.Service, gl *live.GrafanaLive) Service { - if !cfg.IsDashboardPreviesEnabled() { +func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureToggles, renderService rendering.Service, gl *live.GrafanaLive) Service { + if !features.IsDashboardPreviewsEnabled() { return &dummyService{} } diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index ee72b162801..459c8e8e42b 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -132,7 +132,7 @@ func (o *OSSImpl) Section(section string) Section { func (OSSImpl) RegisterReloadHandler(string, ReloadHandler) {} func (o OSSImpl) IsFeatureToggleEnabled(name string) bool { - return o.Cfg.FeatureToggles[name] + return o.Cfg.IsFeatureToggleEnabled(name) } type keyValImpl struct { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 279b2f72c5a..9712d1d3632 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -342,8 +342,10 @@ type Cfg struct { ApiKeyMaxSecondsToLive int64 - // Use to enable new features which may still be in alpha/beta stage. - FeatureToggles map[string]bool + // Check if a feature toggle is enabled + // @deprecated + IsFeatureToggleEnabled func(key string) bool // filled in dynamically + AnonymousEnabled bool AnonymousOrgName string AnonymousOrgRole string @@ -429,41 +431,6 @@ type Cfg struct { UnifiedAlerting UnifiedAlertingSettings } -// IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables -func (cfg Cfg) IsLiveConfigEnabled() bool { - return cfg.FeatureToggles["live-config"] -} - -// IsLiveConfigEnabled returns true if live should be able to save configs to SQL tables -func (cfg Cfg) IsDashboardPreviesEnabled() bool { - return cfg.FeatureToggles["dashboardPreviews"] -} - -// IsTrimDefaultsEnabled returns whether the standalone trim dashboard default feature is enabled. -func (cfg Cfg) IsTrimDefaultsEnabled() bool { - return cfg.FeatureToggles["trimDefaults"] -} - -// IsDatabaseMetricsEnabled returns whether the database instrumentation feature is enabled. -func (cfg Cfg) IsDatabaseMetricsEnabled() bool { - return cfg.FeatureToggles["database_metrics"] -} - -// IsHTTPRequestHistogramDisabled returns whether the request historgrams is disabled. -// This feature toggle will be removed in Grafana 8.x but gives the operator -// some graceperiod to update all the monitoring tools. -func (cfg Cfg) IsHTTPRequestHistogramDisabled() bool { - return cfg.FeatureToggles["disable_http_request_histogram"] -} - -func (cfg Cfg) IsNewNavigationEnabled() bool { - return cfg.FeatureToggles["newNavigation"] -} - -func (cfg Cfg) IsServiceAccountEnabled() bool { - return cfg.FeatureToggles["service-accounts"] -} - type CommandLineArgs struct { Config string HomePath string diff --git a/pkg/setting/setting_feature_toggles.go b/pkg/setting/setting_feature_toggles.go index 4c103b31030..abef83d7c48 100644 --- a/pkg/setting/setting_feature_toggles.go +++ b/pkg/setting/setting_feature_toggles.go @@ -3,42 +3,23 @@ package setting import ( "strconv" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/grafana/grafana/pkg/util" "gopkg.in/ini.v1" ) -var ( - featureToggleInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{ - Name: "feature_toggles_info", - Help: "info metric that exposes what feature toggles are enabled or not", - Namespace: "grafana", - }, []string{"name"}) - - defaultFeatureToggles = map[string]bool{ - "recordedQueries": false, - "accesscontrol": false, - "service-accounts": false, - "httpclientprovider_azure_auth": false, - } -) - +// @deprecated -- should use `featuremgmt.FeatureToggles` func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { - toggles, err := overrideDefaultWithConfiguration(iniFile, defaultFeatureToggles) + section := iniFile.Section("feature_toggles") + toggles, err := ReadFeatureTogglesFromInitFile(section) if err != nil { return err } - - cfg.FeatureToggles = toggles - + cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] } return nil } -func overrideDefaultWithConfiguration(iniFile *ini.File, featureToggles map[string]bool) (map[string]bool, error) { - // Read and populate feature toggles list - featureTogglesSection := iniFile.Section("feature_toggles") +func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) { + featureToggles := make(map[string]bool, 10) // parse the comma separated list in `enable`. featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "") @@ -60,15 +41,5 @@ func overrideDefaultWithConfiguration(iniFile *ini.File, featureToggles map[stri featureToggles[v.Name()] = b } - - // track if feature toggles are enabled or not using an info metric - for k, v := range featureToggles { - if v { - featureToggleInfo.WithLabelValues(k).Set(1) - } else { - featureToggleInfo.WithLabelValues(k).Set(0) - } - } - return featureToggles, nil } diff --git a/pkg/setting/setting_feature_toggles_test.go b/pkg/setting/setting_feature_toggles_test.go index 92ec0f03095..b0c3730bcad 100644 --- a/pkg/setting/setting_feature_toggles_test.go +++ b/pkg/setting/setting_feature_toggles_test.go @@ -14,7 +14,6 @@ func TestFeatureToggles(t *testing.T) { conf map[string]string err error expectedToggles map[string]bool - defaultToggles map[string]bool }{ { name: "can parse feature toggles passed in the `enable` array", @@ -58,18 +57,6 @@ func TestFeatureToggles(t *testing.T) { expectedToggles: map[string]bool{}, err: strconv.ErrSyntax, }, - { - name: "should override default feature toggles", - defaultToggles: map[string]bool{ - "feature1": true, - }, - conf: map[string]string{ - "feature1": "false", - }, - expectedToggles: map[string]bool{ - "feature1": false, - }, - }, } for _, tc := range testCases { @@ -81,12 +68,7 @@ func TestFeatureToggles(t *testing.T) { require.ErrorIs(t, err, nil) } - dt := map[string]bool{} - if len(tc.defaultToggles) > 0 { - dt = tc.defaultToggles - } - - featureToggles, err := overrideDefaultWithConfiguration(f, dt) + featureToggles, err := ReadFeatureTogglesFromInitFile(toggles) require.ErrorIs(t, err, tc.err) if err == nil { diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index d1332086ec3..b4a74ff4f47 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -79,7 +79,7 @@ func (cfg *Cfg) readUnifiedAlertingEnabledSetting(section *ini.Section) (*bool, // the unified alerting is not enabled by default. First, check the feature flag if err != nil { // TODO: Remove in Grafana v9 - if cfg.FeatureToggles["ngalert"] { + if cfg.IsFeatureToggleEnabled("ngalert") { cfg.Logger.Warn("ngalert feature flag is deprecated: use unified alerting enabled setting instead") enabled = true // feature flag overrides the legacy alerting setting. diff --git a/pkg/setting/setting_unified_alerting_test.go b/pkg/setting/setting_unified_alerting_test.go index d890700032c..63c9d790238 100644 --- a/pkg/setting/setting_unified_alerting_test.go +++ b/pkg/setting/setting_unified_alerting_test.go @@ -143,6 +143,7 @@ func TestUnifiedAlertingSettings(t *testing.T) { t.Run(tc.desc, func(t *testing.T) { f := ini.Empty() cfg := NewCfg() + cfg.IsFeatureToggleEnabled = func(key string) bool { return false } unifiedAlertingSec, err := f.NewSection("unified_alerting") require.NoError(t, err) for k, v := range tc.unifiedAlertingOptions { diff --git a/pkg/tsdb/testdatasource/stream_handler.go b/pkg/tsdb/testdatasource/stream_handler.go index 0b49ea2d24c..416eb75d59b 100644 --- a/pkg/tsdb/testdatasource/stream_handler.go +++ b/pkg/tsdb/testdatasource/stream_handler.go @@ -37,7 +37,7 @@ func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStrea } } - if s.cfg.FeatureToggles["live-pipeline"] { + if s.features.IsLivePipelineEnabled() { // While developing Live pipeline avoid sending initial data. initialData = nil } @@ -126,7 +126,7 @@ func (s *Service) runTestStream(ctx context.Context, path string, conf testStrea } mode := data.IncludeDataOnly - if s.cfg.FeatureToggles["live-pipeline"] { + if s.features.IsLivePipelineEnabled() { mode = data.IncludeAll } diff --git a/pkg/tsdb/testdatasource/testdata.go b/pkg/tsdb/testdatasource/testdata.go index bac97f146bc..69b3a6c9299 100644 --- a/pkg/tsdb/testdatasource/testdata.go +++ b/pkg/tsdb/testdatasource/testdata.go @@ -10,11 +10,13 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg) *Service { +func ProvideService(cfg *setting.Cfg, features *featuremgmt.FeatureToggles) *Service { s := &Service{ + features: features, queryMux: datasource.NewQueryTypeMux(), scenarios: map[string]*Scenario{}, frame: data.NewFrame("testdata", @@ -46,6 +48,7 @@ type Service struct { labelFrame *data.Frame queryMux *datasource.QueryTypeMux resourceHandler backend.CallResourceHandler + features *featuremgmt.FeatureToggles } func (s *Service) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 262ac14abd4..adb40eb6f11 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -83,13 +83,13 @@ export class ContextSrv { } accessControlEnabled(): boolean { - return featureEnabled('accesscontrol') && Boolean(config.featureToggles['accesscontrol']); + return featureEnabled(config.featureToggles.accesscontrol); } // Checks whether user has required permission hasPermissionInMetadata(action: AccessControlAction | string, object: WithAccessControlMetadata): boolean { // Fallback if access control disabled - if (!config.featureToggles['accesscontrol']) { + if (!config.featureToggles.accesscontrol) { return true; } @@ -99,7 +99,7 @@ export class ContextSrv { // Checks whether user has required permission hasPermission(action: AccessControlAction | string): boolean { // Fallback if access control disabled - if (!config.featureToggles['accesscontrol']) { + if (!config.featureToggles.accesscontrol) { return true; } @@ -126,14 +126,14 @@ export class ContextSrv { } hasAccessToExplore() { - if (config.featureToggles['accesscontrol']) { + if (config.featureToggles.accesscontrol) { return this.hasPermission(AccessControlAction.DataSourcesExplore); } return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled; } hasAccess(action: string, fallBack: boolean) { - if (!config.featureToggles['accesscontrol']) { + if (!config.featureToggles.accesscontrol) { return fallBack; } return this.hasPermission(action); @@ -141,7 +141,7 @@ export class ContextSrv { // evaluates access control permissions, granting access if the user has any of them; uses fallback if access control is disabled evaluatePermission(fallback: () => string[], actions: string[]) { - if (!config.featureToggles['accesscontrol']) { + if (!config.featureToggles.accesscontrol) { return fallback(); } if (actions.some((action) => this.hasPermission(action))) { diff --git a/public/app/core/utils/accessControl.ts b/public/app/core/utils/accessControl.ts index c9e18f65b2b..76dd4f484f2 100644 --- a/public/app/core/utils/accessControl.ts +++ b/public/app/core/utils/accessControl.ts @@ -2,7 +2,7 @@ import config from '../../core/config'; // accessControlQueryParam adds an additional accesscontrol=true param to params when accesscontrol is enabled export function accessControlQueryParam(params = {}) { - if (!config.featureToggles['accesscontrol']) { + if (!config.featureToggles.accesscontrol) { return params; } return { ...params, accesscontrol: true }; diff --git a/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx b/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx index b455f9bd42a..d54d48fc084 100644 --- a/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx @@ -49,14 +49,14 @@ describe('PluginListItemBadges', () => { }); it('renders an enterprise badge (when a license is valid)', () => { - config.licenseInfo.enabledFeatures = { 'enterprise.plugins': true }; + config.featureToggles = { 'enterprise.plugins': true }; render(); expect(screen.getByText(/enterprise/i)).toBeVisible(); expect(screen.queryByRole('button', { name: /learn more/i })).not.toBeInTheDocument(); }); it('renders an enterprise badge with icon and link (when a license is invalid)', () => { - config.licenseInfo.enabledFeatures = {}; + config.featureToggles = {}; render(); expect(screen.getByText(/enterprise/i)).toBeVisible(); expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument(); diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index aaea83fea93..089a5e67ef6 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -90,7 +90,7 @@ describe('Plugin details page', () => { afterEach(() => { jest.clearAllMocks(); config.pluginAdminExternalManageEnabled = false; - config.licenseInfo.enabledFeatures = {}; + config.featureToggles = {}; }); afterAll(() => { @@ -325,7 +325,7 @@ describe('Plugin details page', () => { }); it('should display an install button for enterprise plugins if license is valid', async () => { - config.licenseInfo.enabledFeatures = { 'enterprise.plugins': true }; + config.featureToggles = { 'enterprise.plugins': true }; const { queryByRole } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true }); @@ -333,7 +333,7 @@ describe('Plugin details page', () => { }); it('should not display install button for enterprise plugins if license is invalid', async () => { - config.licenseInfo.enabledFeatures = {}; + config.featureToggles = {}; const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, isEnterprise: true }); @@ -772,7 +772,7 @@ describe('Plugin details page', () => { }); it('should not display an install button for enterprise plugins if license is valid', async () => { - config.licenseInfo.enabledFeatures = { 'enterprise.plugins': true }; + config.featureToggles = { 'enterprise.plugins': true }; const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true }); await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument()); diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx index 6497b77e6de..ac8b13da25f 100644 --- a/public/app/features/teams/TeamPages.test.tsx +++ b/public/app/features/teams/TeamPages.test.tsx @@ -10,9 +10,7 @@ import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps jest.mock('@grafana/runtime/src/config', () => ({ ...((jest.requireActual('@grafana/runtime/src/config') as unknown) as object), config: { - licenseInfo: { - enabledFeatures: { teamsync: true }, - }, + featureToggles: { teamsync: true }, }, }));