FeatureFlags: manage feature flags outside of settings.Cfg (#43692)

This commit is contained in:
Ryan McKinley 2022-01-20 13:42:05 -08:00 committed by GitHub
parent 7fbc7d019a
commit f94c0decbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 1244 additions and 252 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -40,7 +40,6 @@ export interface LicenseInfo {
licenseUrl: string;
stateInfo: string;
edition: GrafanaEdition;
enabledFeatures: { [key: string]: boolean };
}
/**

View File

@ -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

View File

@ -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]);
};

View File

@ -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))

View File

@ -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"))
}

View File

@ -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: &quota.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: &quota.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()

View File

@ -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
}

View File

@ -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,

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -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"

View File

@ -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))
}

View File

@ -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)

View File

@ -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))

View File

@ -14,8 +14,6 @@ type Licensing interface {
StateInfo() string
EnabledFeatures() map[string]bool
FeatureEnabled(feature string) bool
}

View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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(

View File

@ -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() {

View File

@ -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}

View File

@ -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:-`
}

View File

@ -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...),
}
}

View File

@ -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)
})
}

View File

@ -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,
},
}
)

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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))
}

View File

@ -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")

View File

@ -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

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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()
}

View File

@ -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

View File

@ -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) {

View File

@ -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(

View File

@ -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()

View File

@ -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
}

View File

@ -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{

View File

@ -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
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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{}
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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.

View File

@ -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 {

View File

@ -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
}

View File

@ -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) {

View File

@ -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))) {

View File

@ -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 };

View File

@ -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(<PluginListItemBadges plugin={{ ...plugin, isEnterprise: true }} />);
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(<PluginListItemBadges plugin={{ ...plugin, isEnterprise: true }} />);
expect(screen.getByText(/enterprise/i)).toBeVisible();
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();

View File

@ -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());

View File

@ -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 },
},
}));