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