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/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 94d9a8b6eb3..971014422c9 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 @@ -10,13 +16,6 @@ export interface FeatureToggles { [name: string]: boolean | undefined; // support any string value - recordedQueries?: boolean; - teamsync?: boolean; - ldapsync?: boolean; - caching?: boolean; - dspermissions?: boolean; - analytics?: boolean; - ['enterprise.plugins']?: boolean; trimDefaults?: boolean; envelopeEncryption?: boolean; httpclientprovider_azure_auth?: boolean; @@ -36,4 +35,5 @@ export interface FeatureToggles { newNavigation?: boolean; showFeatureFlagsInUI?: boolean; disable_http_request_histogram?: boolean; + validatedQueries?: boolean; } diff --git a/pkg/api/api.go b/pkg/api/api.go index 2f574fdd2b1..9074cf3f37a 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" acmiddleware "github.com/grafana/grafana/pkg/services/accesscontrol/middleware" + "github.com/grafana/grafana/pkg/services/featuremgmt" sa "github.com/grafana/grafana/pkg/services/serviceaccounts/manager" ) @@ -437,7 +438,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.IsEnabled(featuremgmt.FlagLivePipeline) { // 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 +461,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.IsEnabled(featuremgmt.FlagShowFeatureFlagsInUI) { + 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..beece581a68 100644 --- a/pkg/api/apikey.go +++ b/pkg/api/apikey.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/apikeygen" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/web" ) @@ -83,7 +84,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.IsEnabled(featuremgmt.FlagServiceAccounts) { // 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 +121,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.IsEnabled(featuremgmt.FlagServiceAccounts) { 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 09117c2c183..28361aac574 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/resourceservices" "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" @@ -215,8 +216,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(featuremgmt.FlagAccesscontrol) + cfg.IsFeatureToggleEnabled = features.IsEnabled cfg.Quota.Enabled = false bus := bus.GetBus() @@ -224,6 +225,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), @@ -298,13 +300,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) } @@ -312,6 +326,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 @@ -325,6 +342,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}, @@ -344,7 +362,7 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont require.NoError(t, err) hs.TeamPermissionsService = teamPermissionService } else { - ac = ossaccesscontrol.ProvideService(cfg, &usagestats.UsageStatsMock{T: t}) + ac = ossaccesscontrol.ProvideService(hs.Features, &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..c44ef24a034 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.WithFeatures() 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..65a3a4646c2 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -249,7 +249,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "edition": hs.License.Edition(), "enabledFeatures": hs.License.EnabledFeatures(), }, - "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 dc79080df01..87619a0ca43 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -36,6 +36,7 @@ import ( "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/encryption" + "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" @@ -77,6 +78,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 @@ -138,7 +140,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, @@ -171,6 +173,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..107270d2919 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -85,7 +86,7 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) SortWeight: dtos.WeightPlugin, } - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { appLink.Section = dtos.NavSectionPlugin } else { appLink.Section = dtos.NavSectionCore @@ -143,7 +144,9 @@ 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.IsEnabled(featuremgmt.FlagServiceAccounts) && + hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId) } func enableTeams(hs *HTTPServer, c *models.ReqContext) bool { @@ -154,7 +157,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.IsEnabled(featuremgmt.FlagNewNavigation) { navTree = append(navTree, &dtos.NavLink{ Text: "Home", Id: "home", @@ -165,7 +168,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if hasEditPerm && !hs.Cfg.IsNewNavigationEnabled() { + if hasEditPerm && !hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { children := hs.buildCreateNavLinks(c) navTree = append(navTree, &dtos.NavLink{ Text: "Create", @@ -181,7 +184,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm) dashboardsUrl := "/" - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { dashboardsUrl = "/dashboards" } @@ -312,7 +315,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if hs.Cfg.FeatureToggles["live-pipeline"] { + if hs.Features.IsEnabled(featuremgmt.FlagLivePipeline) { liveNavLinks := []*dtos.NavLink{} liveNavLinks = append(liveNavLinks, &dtos.NavLink{ @@ -346,7 +349,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto SortWeight: dtos.WeightConfig, Children: configNodes, } - if hs.Cfg.IsNewNavigationEnabled() { + if hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { configNode.Section = dtos.NavSectionConfig } else { configNode.Section = dtos.NavSectionCore @@ -358,7 +361,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.IsEnabled(featuremgmt.FlagNewNavigation) { navSection = dtos.NavSectionConfig } serverAdminNode := navlinks.GetServerAdminNode(adminNavLinks, navSection) @@ -386,7 +389,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.IsEnabled(featuremgmt.FlagNewNavigation) { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Home", Id: "home", Url: hs.Cfg.AppSubURL + "/", Icon: "home-alt", HideFromTabs: true, }) @@ -417,7 +420,7 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b }) } - if hasEditPerm && hs.Cfg.IsNewNavigationEnabled() { + if hasEditPerm && hs.Features.IsEnabled(featuremgmt.FlagNewNavigation) { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) @@ -622,7 +625,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat LoadingLogo: "public/img/grafana_icon.svg", } - if hs.Cfg.FeatureToggles["accesscontrol"] { + if hs.Features.IsEnabled(featuremgmt.FlagAccesscontrol) { 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 3ab227bb347..7aeea8e5c81 100644 --- a/pkg/api/org.go +++ b/pkg/api/org.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" + "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" @@ -89,7 +90,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.IsEnabled(featuremgmt.FlagAccesscontrol) 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 1330d8cb60d..009e00d18a4 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 db6fe56c881..4c2a59eee25 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 2cc7488b479..53aea94d8ff 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -19,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.IsEnabled(featuremgmt.FlagAccesscontrol) if !accessControlEnabled && c.OrgRole == models.ROLE_VIEWER { return response.Error(403, "Not allowed to create team.", nil) } diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 2be1eb6ef23..cc0cd45daf4 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -61,7 +62,7 @@ func (hs *HTTPServer) AddTeamMember(c *models.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "teamId is invalid", err) } - if !hs.Cfg.FeatureToggles["accesscontrol"] { + if !hs.Features.IsEnabled(featuremgmt.FlagAccesscontrol) { if err := hs.teamGuardian.CanAdmin(c.Req.Context(), cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil { return response.Error(403, "Not allowed to add team member", err) } @@ -101,7 +102,7 @@ func (hs *HTTPServer) UpdateTeamMember(c *models.ReqContext) response.Response { } orgId := c.OrgId - if !hs.Cfg.FeatureToggles["accesscontrol"] { + if !hs.Features.IsEnabled(featuremgmt.FlagAccesscontrol) { if err := hs.teamGuardian.CanAdmin(c.Req.Context(), orgId, teamId, c.SignedInUser); err != nil { return response.Error(403, "Not allowed to update team member", err) } @@ -144,7 +145,7 @@ func (hs *HTTPServer) RemoveTeamMember(c *models.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "userId is invalid", err) } - if !hs.Cfg.FeatureToggles["accesscontrol"] { + if !hs.Features.IsEnabled(featuremgmt.FlagAccesscontrol) { if err := hs.teamGuardian.CanAdmin(c.Req.Context(), orgId, teamId, c.SignedInUser); err != nil { return response.Error(403, "Not allowed to remove team member", err) } diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index 97dd083e59d..f3118ed687e 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -32,9 +32,7 @@ func (stub *testLogger) Warn(testMessage string, ctx ...interface{}) { func TestTeamAPIEndpoint(t *testing.T) { t.Run("Given two teams", func(t *testing.T) { - hs := &HTTPServer{ - Cfg: setting.NewCfg(), - } + hs := setupSimpleHTTPServer(nil) hs.SQLStore = sqlstore.InitTestDB(t) loggedInUserScenario(t, "When calling GET on", "/api/teams/search", "/api/teams/search", func(sc *scenarioContext) { @@ -73,9 +71,7 @@ func TestTeamAPIEndpoint(t *testing.T) { }) t.Run("When creating team with API key", func(t *testing.T) { - hs := &HTTPServer{ - Cfg: setting.NewCfg(), - } + hs := setupSimpleHTTPServer(nil) hs.Cfg.EditorsCanAdmin = true teamName := "team foo" diff --git a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go index 020eccfa864..9994dddd9e6 100644 --- a/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go +++ b/pkg/cmd/grafana-cli/commands/secretsmigrations/reencrypt_secrets.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" "github.com/grafana/grafana/pkg/cmd/grafana-cli/runner" "github.com/grafana/grafana/pkg/cmd/grafana-cli/utils" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -178,7 +179,7 @@ func (s alertingSecret) reencrypt(secretsSrv *manager.SecretsService, sess *xorm } func ReEncryptSecrets(_ utils.CommandLine, runner runner.Runner) error { - if !runner.SettingsProvider.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { + if !runner.SettingsProvider.IsFeatureToggleEnabled(featuremgmt.FlagEnvelopeEncryption) { logger.Warn("Envelope encryption is not enabled, quitting...") return nil } diff --git a/pkg/cmd/grafana-cli/runner/wire.go b/pkg/cmd/grafana-cli/runner/wire.go index 15d692558a0..665bd0c48d0 100644 --- a/pkg/cmd/grafana-cli/runner/wire.go +++ b/pkg/cmd/grafana-cli/runner/wire.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/usagestats" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/secrets" secretsDatabase "github.com/grafana/grafana/pkg/services/secrets/database" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -25,6 +26,8 @@ var wireSet = wire.NewSet( localcache.ProvideService, tracing.ProvideService, bus.ProvideBus, + featuremgmt.ProvideManagerService, + featuremgmt.ProvideToggles, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), sqlstore.ProvideService, wire.InterfaceValue(new(usagestats.Service), noOpUsageStats{}), diff --git a/pkg/infra/httpclient/httpclientprovider/http_client_provider.go b/pkg/infra/httpclient/httpclientprovider/http_client_provider.go index d0995552dda..7aab8e0c8cb 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.IsEnabled(featuremgmt.FlagHttpclientproviderAzureAuth) { 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..9fbafaa5342 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.WithFeatures()) 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.WithFeatures()) 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..6429f95f318 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.IsEnabled(featuremgmt.FlagDisableHttpRequestHistogram) { 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/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..a6da7682ec2 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.WithFeatures() 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 9db647a04fc..4a5b226196b 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -36,6 +36,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" @@ -183,6 +184,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, resourceservices.ProvideResourceServices, ) diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol.go index 020262d24f1..83848baadfa 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.IsEnabled(featuremgmt.FlagAccesscontrol) } func (ac *OSSAccessControlService) registerUsageMetrics() { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go b/pkg/services/accesscontrol/ossaccesscontrol/ossaccesscontrol_test.go index cb1710e2779..39c2f7a8264 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.WithFeatures(featuremgmt.FlagAccesscontrol), 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.WithFeatures("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.WithFeatures(), 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.WithFeatures(featuremgmt.FlagAccesscontrol), 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.WithFeatures(featuremgmt.FlagAccesscontrol), 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.WithFeatures(featuremgmt.FlagAccesscontrol) 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..8a19c794f3b --- /dev/null +++ b/pkg/services/featuremgmt/features.go @@ -0,0 +1,96 @@ +package featuremgmt + +import ( + "bytes" + "encoding/json" +) + +type FeatureToggles interface { + IsEnabled(flag string) bool +} + +// FeatureFlagState indicates the quality level +type FeatureFlagState int + +const ( + // FeatureStateUnknown indicates that no state is specified + FeatureStateUnknown FeatureFlagState = 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 FeatureFlagState) 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 FeatureFlagState) 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 *FeatureFlagState) 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 FeatureFlagState `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 +} diff --git a/pkg/services/featuremgmt/manager.go b/pkg/services/featuremgmt/manager.go new file mode 100644 index 00000000000..1b795d0348d --- /dev/null +++ b/pkg/services/featuremgmt/manager.go @@ -0,0 +1,185 @@ +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" +) + +var ( + _ FeatureToggles = (*FeatureManager)(nil) +) + +type FeatureManager struct { + isDevMod bool + licensing models.Licensing + flags map[string]*FeatureFlag + enabled map[string]bool // only the "on" values + 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 _, add := range flags { + if add.Name == "" { + continue // skip it with warning? + } + flag, ok := fm.flags[add.Name] + if !ok { + f := add // make a copy + fm.flags[add.Name] = &f + 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 +} + +// 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} +} 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..3e5994938bf --- /dev/null +++ b/pkg/services/featuremgmt/registry.go @@ -0,0 +1,118 @@ +package featuremgmt + +var ( + // Register each toggle here + standardFeatureFlags = []FeatureFlag{ + { + Name: "trimDefaults", + Description: "Use cue schema to remove values that will be applied automatically", + State: FeatureStateBeta, + }, + { + Name: "envelopeEncryption", + Description: "encrypt secrets", + State: FeatureStateBeta, + }, + + { + Name: "httpclientprovider_azure_auth", + Description: "use http client for azure auth", + State: FeatureStateBeta, + }, + { + Name: "service-accounts", + Description: "support service accounts", + State: FeatureStateBeta, + RequiresLicense: true, + }, + + { + Name: "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: "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 explore", + 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 navigation model", + State: FeatureStateAlpha, + }, + { + Name: "showFeatureFlagsInUI", + Description: "Show feature flags in the settings UI", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, + { + Name: "disable_http_request_histogram", + Description: "Do not create histograms for http requests", + State: FeatureStateAlpha, + }, + { + Name: "validatedQueries", + Description: "only execute the query saved in a panel", + State: FeatureStateAlpha, + RequiresDevMode: true, + }, + } +) diff --git a/pkg/services/featuremgmt/service.go b/pkg/services/featuremgmt/service.go new file mode 100644 index 00000000000..d3e5622f451 --- /dev/null +++ b/pkg/services/featuremgmt/service.go @@ -0,0 +1,76 @@ +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 mgmt +} diff --git a/pkg/services/featuremgmt/service_test.go b/pkg/services/featuremgmt/service_test.go new file mode 100644 index 00000000000..1085ea91f2c --- /dev/null +++ b/pkg/services/featuremgmt/service_test.go @@ -0,0 +1,84 @@ +package featuremgmt + +import ( + "testing" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +func TestFeatureService(t *testing.T) { + license := stubLicenseServier{ + flags: []FeatureFlag{ + { + Name: "a.yes.default", + RequiresLicense: true, + Expression: "true", + }, + { + Name: "a.yes", + RequiresLicense: true, + Expression: "", + }, + { + Name: "b.no", + RequiresLicense: true, + }, + }, + enabled: map[string]bool{ + "a.yes.default": true, + "a.yes": true, + }, + } + require.False(t, license.FeatureEnabled("unknown")) + require.False(t, license.FeatureEnabled("b.no")) + require.True(t, license.FeatureEnabled("a.yes")) + require.True(t, license.FeatureEnabled("a.yes.default")) + + cfg := setting.NewCfg() + mgmt, err := ProvideManagerService(cfg, license) + require.NoError(t, err) + require.NotNil(t, mgmt) + + // Enterprise features do not fall though automatically + require.False(t, mgmt.IsEnabled("a.yes.default")) + require.False(t, mgmt.IsEnabled("a.yes")) // licensed, but not enabled +} + +var ( + _ models.Licensing = (*stubLicenseServier)(nil) +) + +type stubLicenseServier struct { + flags []FeatureFlag + enabled map[string]bool +} + +func (s stubLicenseServier) Expiry() int64 { + return 100 +} + +func (s stubLicenseServier) Edition() string { + return "test" +} + +func (s stubLicenseServier) ContentDeliveryPrefix() string { + return "" +} + +func (s stubLicenseServier) LicenseURL(showAdminLicensingPage bool) string { + return "http://??" +} + +func (s stubLicenseServier) StateInfo() string { + return "ok" +} + +func (s stubLicenseServier) EnabledFeatures() map[string]bool { + return map[string]bool{} +} + +func (s stubLicenseServier) FeatureEnabled(feature string) bool { + return s.enabled[feature] +} 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_gen.go b/pkg/services/featuremgmt/toggles_gen.go new file mode 100644 index 00000000000..430b9fbda56 --- /dev/null +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -0,0 +1,85 @@ +// NOTE: This file is autogenerated + +package featuremgmt + +const ( + // FlagTrimDefaults + // Use cue schema to remove values that will be applied automatically + FlagTrimDefaults = "trimDefaults" + + // FlagEnvelopeEncryption + // encrypt secrets + FlagEnvelopeEncryption = "envelopeEncryption" + + // FlagHttpclientproviderAzureAuth + // use http client for azure auth + FlagHttpclientproviderAzureAuth = "httpclientprovider_azure_auth" + + // FlagServiceAccounts + // support service accounts + FlagServiceAccounts = "service-accounts" + + // FlagDatabaseMetrics + // Add prometheus metrics for database tables + FlagDatabaseMetrics = "database_metrics" + + // FlagDashboardPreviews + // Create and show thumbnails for dashboard search results + FlagDashboardPreviews = "dashboardPreviews" + + // FlagLiveConfig + // Save grafana live configuration in SQL tables + FlagLiveConfig = "live-config" + + // FlagLivePipeline + // enable a generic live processing pipeline + FlagLivePipeline = "live-pipeline" + + // FlagLiveServiceWebWorker + // This will use a webworker thread to processes events rather than the main thread + FlagLiveServiceWebWorker = "live-service-web-worker" + + // FlagQueryOverLive + // Use grafana live websocket to execute backend queries + FlagQueryOverLive = "queryOverLive" + + // FlagTempoSearch + // Enable searching in tempo datasources + FlagTempoSearch = "tempoSearch" + + // FlagTempoBackendSearch + // Use backend for tempo search + FlagTempoBackendSearch = "tempoBackendSearch" + + // FlagTempoServiceGraph + // show service + FlagTempoServiceGraph = "tempoServiceGraph" + + // FlagFullRangeLogsVolume + // Show full range logs volume in explore + FlagFullRangeLogsVolume = "fullRangeLogsVolume" + + // FlagAccesscontrol + // Support robust access control + FlagAccesscontrol = "accesscontrol" + + // FlagPrometheusAzureAuth + // Use azure authentication for prometheus datasource + FlagPrometheusAzureAuth = "prometheus_azure_auth" + + // FlagNewNavigation + // Try the next gen navigation model + FlagNewNavigation = "newNavigation" + + // FlagShowFeatureFlagsInUI + // Show feature flags in the settings UI + FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI" + + // FlagDisableHttpRequestHistogram + // Do not create histograms for http requests + FlagDisableHttpRequestHistogram = "disable_http_request_histogram" + + // FlagValidatedQueries + // only execute the query saved in a panel + FlagValidatedQueries = "validatedQueries" +) diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go new file mode 100644 index 00000000000..28e371617d6 --- /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(` +{{"\t"}}// Flag{{.CamelCase}}{{.Ext}} +{{"\t"}}Flag{{.CamelCase}} = "{{.Flag.Name}}" +`) + if err != nil { + t.Fatal("error reading template", "error", err.Error()) + return "" + } + + data := struct { + CamelCase string + Flag FeatureFlag + Ext string + }{ + CamelCase: "?", + } + + var buff bytes.Buffer + + buff.WriteString(`// NOTE: This file is autogenerated + +package featuremgmt + +const (`) + + for _, flag := range standardFeatureFlags { + data.CamelCase = asCamelCase(flag.Name) + data.Flag = flag + data.Ext = "" + + if flag.Description != "" { + data.Ext += "\n\t// " + flag.Description + } + + _ = tmpl.Execute(&buff, data) + } + buff.WriteString(")\n") + + return buff.String() +} diff --git a/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go b/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go index 2e01653f3fa..9568730fa5f 100644 --- a/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go +++ b/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go @@ -2,6 +2,7 @@ package osskmsproviders import ( "github.com/grafana/grafana/pkg/services/encryption" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/kmsproviders" grafana "github.com/grafana/grafana/pkg/services/kmsproviders/defaultprovider" "github.com/grafana/grafana/pkg/services/secrets" @@ -21,7 +22,7 @@ func ProvideService(enc encryption.Internal, settings setting.Provider) Service } func (s Service) Provide() (map[secrets.ProviderID]secrets.Provider, error) { - if !s.settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { + if !s.settings.IsFeatureToggleEnabled(featuremgmt.FlagEnvelopeEncryption) { return nil, nil } diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 70dfbbda0b3..927f0871538 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.IsEnabled(featuremgmt.FlagLivePipeline) { 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..2b9a3e6ddb7 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.IsEnabled(featuremgmt.FlagTrimDefaults) } 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..958d31f3624 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.WithFeatures(featuremgmt.FlagEnvelopeEncryption) + 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(featuremgmt.FlagEnvelopeEncryption)) + assert.True(tb, features.IsEnabled(featuremgmt.FlagEnvelopeEncryption)) encryption := ossencryption.ProvideService() secretsService, err := ProvideSecretsService( diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index 637f5349e99..0961ae41d84 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/encryption" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/kmsproviders" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" @@ -43,7 +44,7 @@ func ProvideSecretsService( } logger := log.New("secrets") - enabled := settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) + enabled := settings.IsFeatureToggleEnabled(featuremgmt.FlagEnvelopeEncryption) currentProviderID := readCurrentProviderID(settings) if _, ok := providers[currentProviderID]; enabled && !ok { @@ -87,7 +88,7 @@ func (s *SecretsService) registerUsageMetrics() { // Enabled / disabled usageMetrics["stats.encryption.envelope_encryption_enabled.count"] = 0 - if s.settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { + if s.settings.IsFeatureToggleEnabled(featuremgmt.FlagEnvelopeEncryption) { usageMetrics["stats.encryption.envelope_encryption_enabled.count"] = 1 } @@ -130,11 +131,11 @@ func (s *SecretsService) Encrypt(ctx context.Context, payload []byte, opt secret func (s *SecretsService) EncryptWithDBSession(ctx context.Context, payload []byte, opt secrets.EncryptionOptions, sess *xorm.Session) ([]byte, error) { // Use legacy encryption service if envelopeEncryptionFeatureToggle toggle is off - if !s.settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { + if !s.settings.IsFeatureToggleEnabled(featuremgmt.FlagEnvelopeEncryption) { return s.enc.Encrypt(ctx, payload, setting.SecretKey) } - // If encryption secrets.EnvelopeEncryptionFeatureToggle toggle is on, use envelope encryption + // If encryption featuremgmt.FlagEnvelopeEncryption toggle is on, use envelope encryption scope := opt() keyName := s.keyName(scope) @@ -172,12 +173,12 @@ func (s *SecretsService) keyName(scope string) string { } func (s *SecretsService) Decrypt(ctx context.Context, payload []byte) ([]byte, error) { - // Use legacy encryption service if secrets.EnvelopeEncryptionFeatureToggle toggle is off - if !s.settings.IsFeatureToggleEnabled(secrets.EnvelopeEncryptionFeatureToggle) { + // Use legacy encryption service if featuremgmt.FlagEnvelopeEncryption toggle is off + if !s.settings.IsFeatureToggleEnabled(featuremgmt.FlagEnvelopeEncryption) { return s.enc.Decrypt(ctx, payload, setting.SecretKey) } - // If encryption secrets.EnvelopeEncryptionFeatureToggle toggle is on, use envelope encryption + // If encryption featuremgmt.FlagEnvelopeEncryption toggle is on, use envelope encryption if len(payload) == 0 { return nil, fmt.Errorf("unable to decrypt empty payload") } diff --git a/pkg/services/secrets/manager/manager_test.go b/pkg/services/secrets/manager/manager_test.go index df442b49d79..8b0b37ab0f7 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.WithFeatures(featuremgmt.FlagEnvelopeEncryption).IsEnabled, }, } encr := ossencryption.ProvideService() diff --git a/pkg/services/secrets/secrets.go b/pkg/services/secrets/secrets.go index 55af27a78ac..e9e549c7594 100644 --- a/pkg/services/secrets/secrets.go +++ b/pkg/services/secrets/secrets.go @@ -8,10 +8,6 @@ import ( "xorm.io/xorm" ) -const ( - EnvelopeEncryptionFeatureToggle = "envelopeEncryption" -) - // Service is an envelope encryption service in charge of encrypting/decrypting secrets. // It is a replacement for encryption.Service type Service interface { diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index edc76f417fd..985ad0f5637 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.IsEnabled(featuremgmt.FlagServiceAccounts) { return } diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index 0586ab4a900..c02c7a815a2 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.WithFeatures(featuremgmt.FlagServiceAccounts)) m := web.New() signedUser := &models.SignedInUser{ diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 8f7a440d012..a48576097cf 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.IsEnabled(featuremgmt.FlagServiceAccounts) { 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.IsEnabled(featuremgmt.FlagServiceAccounts) { 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..57f986a3fe9 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.WithFeatures("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.WithFeatures("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..86f79a5beba 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 != nil && mg.Cfg.IsFeatureToggleEnabled != nil { + if mg.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagLiveConfig) { addLiveChannelMigrations(mg) } } diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 83e29c2b7ca..fcfe7f54573 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/bus" "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/util" ) @@ -117,7 +118,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(featuremgmt.FlagAccesscontrol) { 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 +181,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(featuremgmt.FlagAccesscontrol) { 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..141870d05e3 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.WithFeatures(featuremgmt.FlagAccesscontrol).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.WithFeatures(featuremgmt.FlagAccesscontrol).IsEnabled seedOrgUsers(t, store, 10) for _, tt := range tests { diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index 1b41ce72f99..2cddc4c7519 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" @@ -330,7 +331,7 @@ func (ss *SQLStore) initEngine(engine *xorm.Engine) error { return err } - if ss.Cfg.IsDatabaseMetricsEnabled() { + if ss.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagDatabaseMetrics) { ss.dbCfg.Type = WrapDatabaseDriverWithHooks(ss.dbCfg.Type, ss.tracer) } @@ -496,6 +497,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..62104f2e60e 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.IsEnabled(featuremgmt.FlagDashboardPreviews) { 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..c71b9e3b03c 100644 --- a/pkg/tsdb/testdatasource/stream_handler.go +++ b/pkg/tsdb/testdatasource/stream_handler.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) var random20HzStreamRegex = regexp.MustCompile(`random-20Hz-stream(-\d+)?`) @@ -37,7 +38,7 @@ func (s *Service) SubscribeStream(_ context.Context, req *backend.SubscribeStrea } } - if s.cfg.FeatureToggles["live-pipeline"] { + if s.features.IsEnabled(featuremgmt.FlagLivePipeline) { // While developing Live pipeline avoid sending initial data. initialData = nil } @@ -126,7 +127,7 @@ func (s *Service) runTestStream(ctx context.Context, path string, conf testStrea } mode := data.IncludeDataOnly - if s.cfg.FeatureToggles["live-pipeline"] { + if s.features.IsEnabled(featuremgmt.FlagLivePipeline) { mode = data.IncludeAll } diff --git a/pkg/tsdb/testdatasource/testdata.go b/pkg/tsdb/testdatasource/testdata.go index bac97f146bc..b99b735d046 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/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 };