mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Feature toggles management: Define get feature toggles api (#72106)
* Feature Toggle Management: Define get feature toggles api * lint
This commit is contained in:
@@ -1626,3 +1626,11 @@ server_name =
|
||||
proxy_address =
|
||||
# Determines if the secure socks proxy should be shown on the datasources page, defaults to true if the feature is enabled
|
||||
show_ui = true
|
||||
|
||||
################################## Feature Management ##############################################
|
||||
[feature_management]
|
||||
# Hides specific feature toggles from the feature management page
|
||||
hidden_toggles =
|
||||
|
||||
# Disables updating specific feature toggles in the feature management page
|
||||
read_only_toggles =
|
||||
@@ -1517,3 +1517,8 @@
|
||||
# The address of the socks5 proxy datasources should connect to
|
||||
; proxy_address =
|
||||
; show_ui = true
|
||||
|
||||
################################## Feature Management ##############################################
|
||||
[feature_management]
|
||||
hidden_toggles =
|
||||
read_only_toggles =
|
||||
@@ -421,6 +421,19 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
Grants: []string{"Admin"},
|
||||
}
|
||||
|
||||
featuremgmtReaderRole := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
Name: "fixed:featuremgmt:reader",
|
||||
DisplayName: "Feature Management reader",
|
||||
Description: "Read feature toggles",
|
||||
Group: "Feature Management",
|
||||
Permissions: []ac.Permission{
|
||||
{Action: ac.ActionFeatureManagementRead},
|
||||
},
|
||||
},
|
||||
Grants: []string{"Admin"},
|
||||
}
|
||||
|
||||
return hs.accesscontrolService.DeclareFixedRoles(
|
||||
provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
|
||||
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
|
||||
@@ -428,7 +441,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole,
|
||||
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
|
||||
foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
|
||||
publicDashboardsWriterRole,
|
||||
publicDashboardsWriterRole, featuremgmtReaderRole,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -421,6 +421,12 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
pluginRoute.Get("/:pluginId/metrics", reqOrgAdmin, routing.Wrap(hs.CollectPluginMetrics))
|
||||
})
|
||||
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) {
|
||||
apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) {
|
||||
featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles)
|
||||
})
|
||||
}
|
||||
|
||||
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
|
||||
apiRoute.Any("/datasources/proxy/:id/*", authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequest)
|
||||
apiRoute.Any("/datasources/proxy/uid/:uid/*", authorize(ac.EvalPermission(datasources.ActionQuery)), hs.ProxyDataSourceRequestWithUID)
|
||||
|
||||
30
pkg/api/featuremgmt.go
Normal file
30
pkg/api/featuremgmt.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
)
|
||||
|
||||
func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.Response {
|
||||
featureMgmtCfg := hs.Cfg.FeatureManagement
|
||||
|
||||
features := hs.Features.GetFlags()
|
||||
enabledFeatures := hs.Features.GetEnabled(ctx.Req.Context())
|
||||
|
||||
for i := 0; i < len(features); {
|
||||
ft := features[i]
|
||||
if _, ok := featureMgmtCfg.HiddenToggles[ft.Name]; ok {
|
||||
features = append(features[:i], features[i+1:]...) // remove feature
|
||||
continue
|
||||
}
|
||||
if _, ok := featureMgmtCfg.ReadOnlyToggles[ft.Name]; ok {
|
||||
features[i].ReadOnly = true
|
||||
}
|
||||
features[i].Enabled = enabledFeatures[ft.Name]
|
||||
i++
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, features)
|
||||
}
|
||||
97
pkg/api/featuremgmt_test.go
Normal file
97
pkg/api/featuremgmt_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/services/user/usertest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web/webtest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetFeatureToggles(t *testing.T) {
|
||||
type testCase struct {
|
||||
desc string
|
||||
permissions []accesscontrol.Permission
|
||||
features []interface{}
|
||||
expectedCode int
|
||||
hiddenTogles map[string]struct{}
|
||||
readOnlyToggles map[string]struct{}
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
desc: "should not be able to get feature toggles without permissions",
|
||||
permissions: []accesscontrol.Permission{},
|
||||
features: []interface{}{},
|
||||
expectedCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
desc: "should be able to get feature toggles with correct permissions",
|
||||
permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}},
|
||||
features: []interface{}{"toggle1", true, "toggle2", false},
|
||||
expectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
desc: "hidden toggles are not present in the response",
|
||||
permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}},
|
||||
features: []interface{}{"toggle1", true, "toggle2", false},
|
||||
expectedCode: http.StatusOK,
|
||||
hiddenTogles: map[string]struct{}{"toggle1": {}},
|
||||
},
|
||||
{
|
||||
desc: "read only toggles have the readOnly field set",
|
||||
permissions: []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}},
|
||||
features: []interface{}{"toggle1", true, "toggle2", false},
|
||||
expectedCode: http.StatusOK,
|
||||
hiddenTogles: map[string]struct{}{"toggle1": {}},
|
||||
readOnlyToggles: map[string]struct{}{"toggle2": {}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
cfg.FeatureManagement.HiddenToggles = tt.hiddenTogles
|
||||
cfg.FeatureManagement.ReadOnlyToggles = tt.readOnlyToggles
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.Cfg = cfg
|
||||
hs.Features = featuremgmt.WithFeatures(append([]interface{}{"featureToggleAdminPage", true}, tt.features...)...)
|
||||
hs.orgService = orgtest.NewOrgServiceFake()
|
||||
hs.userService = &usertest.FakeUserService{
|
||||
ExpectedUser: &user.User{ID: 1},
|
||||
}
|
||||
})
|
||||
|
||||
req := webtest.RequestWithSignedInUser(server.NewGetRequest("/api/featuremgmt"), userWithPermissions(1, tt.permissions))
|
||||
res, err := server.SendJSON(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
assert.Equal(t, tt.expectedCode, res.StatusCode)
|
||||
|
||||
if tt.expectedCode == http.StatusOK {
|
||||
var result []featuremgmt.FeatureFlag
|
||||
err := json.NewDecoder(res.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, ft := range result {
|
||||
if _, ok := tt.hiddenTogles[ft.Name]; ok {
|
||||
t.Fail()
|
||||
}
|
||||
if _, ok := tt.readOnlyToggles[ft.Name]; ok {
|
||||
assert.True(t, ft.ReadOnly, tt.desc)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 3-len(tt.hiddenTogles), len(result), tt.desc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -457,6 +457,9 @@ const (
|
||||
// Alerting provisioning actions
|
||||
ActionAlertingProvisioningRead = "alert.provisioning:read"
|
||||
ActionAlertingProvisioningWrite = "alert.provisioning:write"
|
||||
|
||||
// Feature Management actions
|
||||
ActionFeatureManagementRead = "featuremgmt.read"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -113,4 +113,7 @@ type FeatureFlag struct {
|
||||
RequiresLicense bool `json:"requiresLicense,omitempty"` // Must be enabled in the license
|
||||
FrontendOnly bool `json:"frontend,omitempty"` // change is only seen in the frontend
|
||||
HideFromDocs bool `json:"hideFromDocs,omitempty"` // don't add the values to docs
|
||||
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
|
||||
// WithFeatures([]interface{}{"my_feature", "other_feature"}) or WithFeatures([]interface{}{"my_feature", true})
|
||||
func WithFeatures(spec ...interface{}) *FeatureManager {
|
||||
count := len(spec)
|
||||
features := make(map[string]*FeatureFlag, count)
|
||||
enabled := make(map[string]bool, count)
|
||||
|
||||
idx := 0
|
||||
@@ -165,10 +166,11 @@ func WithFeatures(spec ...interface{}) *FeatureManager {
|
||||
idx++
|
||||
}
|
||||
|
||||
features[key] = &FeatureFlag{Name: key, Enabled: val}
|
||||
if val {
|
||||
enabled[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
return &FeatureManager{enabled: enabled}
|
||||
return &FeatureManager{enabled: enabled, flags: features}
|
||||
}
|
||||
|
||||
@@ -551,6 +551,9 @@ type Cfg struct {
|
||||
// This needs to be on the global object since its used in the
|
||||
// sqlstore package and HTTP middlewares.
|
||||
DatabaseInstrumentQueries bool
|
||||
|
||||
// Feature Management Settings
|
||||
FeatureManagement FeatureMgmtSettings
|
||||
}
|
||||
|
||||
// AddChangePasswordLink returns if login form is disabled or not since
|
||||
@@ -1236,6 +1239,8 @@ func (cfg *Cfg) Load(args CommandLineArgs) error {
|
||||
logSection := iniFile.Section("log")
|
||||
cfg.UserFacingDefaultError = logSection.Key("user_facing_default_error").MustString("please inspect Grafana server log for details")
|
||||
|
||||
cfg.readFeatureManagementConfig()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
32
pkg/setting/setting_featuremgmt.go
Normal file
32
pkg/setting/setting_featuremgmt.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type FeatureMgmtSettings struct {
|
||||
HiddenToggles map[string]struct{}
|
||||
ReadOnlyToggles map[string]struct{}
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readFeatureManagementConfig() {
|
||||
section := cfg.Raw.Section("feature_management")
|
||||
|
||||
hiddenToggles := make(map[string]struct{})
|
||||
readOnlyToggles := make(map[string]struct{})
|
||||
|
||||
// parse the comma separated list in `hidden_toggles`.
|
||||
hiddenTogglesStr := valueAsString(section, "hidden_toggles", "")
|
||||
for _, feature := range util.SplitString(hiddenTogglesStr) {
|
||||
hiddenToggles[feature] = struct{}{}
|
||||
}
|
||||
|
||||
// parse the comma separated list in `read_only_toggles`.
|
||||
readOnlyTogglesStr := valueAsString(section, "read_only_toggles", "")
|
||||
for _, feature := range util.SplitString(readOnlyTogglesStr) {
|
||||
readOnlyToggles[feature] = struct{}{}
|
||||
}
|
||||
|
||||
cfg.FeatureManagement.HiddenToggles = hiddenToggles
|
||||
cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles
|
||||
}
|
||||
Reference in New Issue
Block a user