mirror of
https://github.com/grafana/grafana.git
synced 2024-12-23 23:50:28 -06:00
Feature Toggles: Create API for updating feature toggle state from the feature toggle admin page (#73022)
* create roles for writing feature toggles * create update endpoint / handler * api changes * add feature toggle validations * hide toggles based on their state * make FlagFeatureToggle read only * add username log * add username string * refactor for better readability * refactor unit tests so we can do more validations * some skeletoning for the set tests * write unit tests for updater * break helper functions out * update sample ini to match defaults * add more logic to ReadOnly label * add user documentation * fix lint issue * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: J Stickler <julie.stickler@grafana.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: J Stickler <julie.stickler@grafana.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: J Stickler <julie.stickler@grafana.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: J Stickler <julie.stickler@grafana.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: J Stickler <julie.stickler@grafana.com> * Update docs/sources/setup-grafana/configure-grafana/_index.md Co-authored-by: J Stickler <julie.stickler@grafana.com> --------- Co-authored-by: IbrahimCSAE <ibrahim.mdev@gmail.com> Co-authored-by: J Stickler <julie.stickler@grafana.com>
This commit is contained in:
parent
d9695eb507
commit
779e0fe311
@ -1631,7 +1631,14 @@ proxy_address =
|
||||
show_ui = true
|
||||
|
||||
################################## Feature Management ##############################################
|
||||
# Options to configure the experimental Feature Toggle Admin Page feature, which is behind the `featureToggleAdminPage` feature toggle. Use at your own risk.
|
||||
[feature_management]
|
||||
# Allows editing of feature toggles in the feature management page
|
||||
allow_editing = false
|
||||
|
||||
# Allow customization of URL for the controller that manages feature toggles
|
||||
update_controller_url =
|
||||
|
||||
# Hides specific feature toggles from the feature management page
|
||||
hidden_toggles =
|
||||
|
||||
|
@ -1524,5 +1524,12 @@
|
||||
|
||||
################################## Feature Management ##############################################
|
||||
[feature_management]
|
||||
hidden_toggles =
|
||||
read_only_toggles =
|
||||
# Options to configure the experimental Feature Toggle Admin Page feature, which is behind the `featureToggleAdminPage` feature toggle. Use at your own risk.
|
||||
# Allow editing of feature toggles in the feature management page
|
||||
;allow_editing = false
|
||||
# Allow customization of URL for the controller that manages feature toggles
|
||||
;update_controller_url =
|
||||
# Hide specific feature toggles from the feature management page
|
||||
;hidden_toggles =
|
||||
# Disable updating specific feature toggles in the feature management page
|
||||
;read_only_toggles =
|
@ -2257,6 +2257,36 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise]({{<
|
||||
|
||||
Keys of alpha features to enable, separated by space.
|
||||
|
||||
<hr>
|
||||
|
||||
## [feature_management]
|
||||
|
||||
The options in this section configure the experimental Feature Toggle Admin Page feature, which is enabled using the `featureToggleAdminPage` feature toggle. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available.
|
||||
|
||||
Please see [Configure feature toggles]({{< relref "/feature-toggles" >}}) for more information.
|
||||
|
||||
### allow_editing
|
||||
|
||||
Lets you switch the feature toggle state in the feature management page. The default is `false`.
|
||||
|
||||
### update_controller_url
|
||||
|
||||
Set the URL of the controller that manages the feature toggle updates. If not set, feature toggles in the feature management page will be read-only.
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
The API for feature toggle updates has not been defined yet.
|
||||
{{% /admonition %}}
|
||||
|
||||
### hidden_toggles
|
||||
|
||||
Hide additional specific feature toggles from the feature management page. By default, feature toggles in the `unknown`, `experimental`, and `private preview` stages are hidden from the UI. Use this option to hide toggles in the `public preview`, `general availability`, and `deprecated` stages.
|
||||
|
||||
### read_only_toggles
|
||||
|
||||
Use to disable updates for additional specific feature toggles in the feature management page. By default, feature toggles can only be updated if they are in the `general availability` and `deprecated`stages. Use this option to disable updates for toggles in those stages.
|
||||
|
||||
<hr>
|
||||
|
||||
## [date_formats]
|
||||
|
||||
{{% admonition type="note" %}}
|
||||
|
@ -434,6 +434,19 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
Grants: []string{"Admin"},
|
||||
}
|
||||
|
||||
featuremgmtWriterRole := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
Name: "fixed:featuremgmt:writer",
|
||||
DisplayName: "Feature Management writer",
|
||||
Description: "Write feature toggles",
|
||||
Group: "Feature Management",
|
||||
Permissions: []ac.Permission{
|
||||
{Action: ac.ActionFeatureManagementWrite},
|
||||
},
|
||||
},
|
||||
Grants: []string{"Admin"},
|
||||
}
|
||||
|
||||
return hs.accesscontrolService.DeclareFixedRoles(
|
||||
provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
|
||||
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
|
||||
@ -441,7 +454,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole,
|
||||
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
|
||||
foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
|
||||
publicDashboardsWriterRole, featuremgmtReaderRole,
|
||||
publicDashboardsWriterRole, featuremgmtReaderRole, featuremgmtWriterRole,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -430,6 +430,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
if hs.Features.IsEnabled(featuremgmt.FlagFeatureToggleAdminPage) {
|
||||
apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) {
|
||||
featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles)
|
||||
featuremgmtRoute.Post("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementWrite)), hs.UpdateFeatureToggle)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -1,30 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
func (hs *HTTPServer) GetFeatureToggles(ctx *contextmodel.ReqContext) response.Response {
|
||||
featureMgmtCfg := hs.Cfg.FeatureManagement
|
||||
|
||||
features := hs.Features.GetFlags()
|
||||
cfg := hs.Cfg.FeatureManagement
|
||||
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
|
||||
// object being returned
|
||||
dtos := make([]featuremgmt.FeatureToggleDTO, 0)
|
||||
|
||||
// loop through features an add features that should be visible to dtos
|
||||
for _, ft := range hs.Features.GetFlags() {
|
||||
if isFeatureHidden(ft, cfg.HiddenToggles) {
|
||||
continue
|
||||
}
|
||||
if _, ok := featureMgmtCfg.ReadOnlyToggles[ft.Name]; ok {
|
||||
features[i].ReadOnly = true
|
||||
dto := featuremgmt.FeatureToggleDTO{
|
||||
Name: ft.Name,
|
||||
Description: ft.Description,
|
||||
Enabled: enabledFeatures[ft.Name],
|
||||
ReadOnly: !isFeatureWriteable(ft, cfg.ReadOnlyToggles) || !isFeatureEditingAllowed(*hs.Cfg),
|
||||
}
|
||||
features[i].Enabled = enabledFeatures[ft.Name]
|
||||
i++
|
||||
|
||||
dtos = append(dtos, dto)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, features)
|
||||
return response.JSON(http.StatusOK, dtos)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response.Response {
|
||||
featureMgmtCfg := hs.Cfg.FeatureManagement
|
||||
if !featureMgmtCfg.AllowEditing {
|
||||
return response.Error(http.StatusForbidden, "feature toggles are read-only", fmt.Errorf("feature toggles are configured to be read-only"))
|
||||
}
|
||||
|
||||
if featureMgmtCfg.UpdateControllerUrl == "" {
|
||||
return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_controller_url is not set"))
|
||||
}
|
||||
|
||||
cmd := featuremgmt.UpdateFeatureTogglesCommand{}
|
||||
if err := web.Bind(ctx.Req, &cmd); err != nil {
|
||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
||||
}
|
||||
|
||||
for _, t := range cmd.FeatureToggles {
|
||||
// make sure flag exists, and only continue if flag is writeable
|
||||
if f, ok := hs.Features.LookupFlag(t.Name); ok && isFeatureWriteable(f, hs.Cfg.FeatureManagement.ReadOnlyToggles) {
|
||||
hs.log.Info("UpdateFeatureToggle: updating toggle", "toggle_name", t.Name, "enabled", t.Enabled, "username", ctx.SignedInUser.Login)
|
||||
// TODO build payload
|
||||
} else {
|
||||
hs.log.Warn("UpdateFeatureToggle: invalid toggle passed in", "toggle_name", t.Name)
|
||||
return response.Error(http.StatusBadRequest, "invalid toggle passed in", fmt.Errorf("invalid toggle passed in: %s", t.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: post to featureMgmtCfg.UpdateControllerUrl and return response status
|
||||
hs.log.Warn("UpdateFeatureToggle: function is unimplemented")
|
||||
|
||||
return response.Error(http.StatusNotImplemented, "UpdateFeatureToggle is unimplemented", fmt.Errorf("UpdateFeatureToggle is unimplemented"))
|
||||
}
|
||||
|
||||
// isFeatureHidden returns whether a toggle should be hidden from the admin page.
|
||||
// filters out statuses Unknown, Experimental, and Private Preview
|
||||
func isFeatureHidden(flag featuremgmt.FeatureFlag, hideCfg map[string]struct{}) bool {
|
||||
if _, ok := hideCfg[flag.Name]; ok {
|
||||
return true
|
||||
}
|
||||
return flag.Stage == featuremgmt.FeatureStageUnknown || flag.Stage == featuremgmt.FeatureStageExperimental || flag.Stage == featuremgmt.FeatureStagePrivatePreview
|
||||
}
|
||||
|
||||
// isFeatureWriteable returns whether a toggle on the admin page can be updated by the user.
|
||||
// only allows writing of GA and Deprecated toggles, and excludes the feature toggle admin page toggle
|
||||
func isFeatureWriteable(flag featuremgmt.FeatureFlag, readOnlyCfg map[string]struct{}) bool {
|
||||
if _, ok := readOnlyCfg[flag.Name]; ok {
|
||||
return false
|
||||
}
|
||||
if flag.Name == featuremgmt.FlagFeatureToggleAdminPage {
|
||||
return false
|
||||
}
|
||||
return flag.Stage == featuremgmt.FeatureStageGeneralAvailability || flag.Stage == featuremgmt.FeatureStageDeprecated
|
||||
}
|
||||
|
||||
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
|
||||
func isFeatureEditingAllowed(cfg setting.Cfg) bool {
|
||||
return cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateControllerUrl != ""
|
||||
}
|
||||
|
@ -1,11 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
@ -18,80 +21,446 @@ import (
|
||||
)
|
||||
|
||||
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{}
|
||||
}
|
||||
readPermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}}
|
||||
|
||||
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": {}},
|
||||
},
|
||||
}
|
||||
t.Run("should not be able to get feature toggles without permissions", func(t *testing.T) {
|
||||
result := runGetScenario(t, []*featuremgmt.FeatureFlag{}, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
|
||||
assert.Len(t, result, 0)
|
||||
})
|
||||
|
||||
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},
|
||||
}
|
||||
})
|
||||
t.Run("should be able to get feature toggles with correct permissions", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
result := runGetScenario(t, features, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
|
||||
assert.Len(t, result, 2)
|
||||
t1, _ := findResult(t, result, "toggle1")
|
||||
assert.True(t, t1.Enabled)
|
||||
t2, _ := findResult(t, result, "toggle2")
|
||||
assert.False(t, t2.Enabled)
|
||||
})
|
||||
|
||||
if tt.expectedCode == http.StatusOK {
|
||||
var result []featuremgmt.FeatureFlag
|
||||
err := json.NewDecoder(res.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
t.Run("toggles hidden by config are not present in the response", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "toggle2", result[0].Name)
|
||||
})
|
||||
|
||||
t.Run("toggles that are read-only by config have the readOnly field set", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
||||
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
|
||||
AllowEditing: true,
|
||||
UpdateControllerUrl: "bogus",
|
||||
}
|
||||
|
||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, "toggle2", result[0].Name)
|
||||
assert.True(t, result[0].ReadOnly)
|
||||
})
|
||||
|
||||
t.Run("feature toggle defailts", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Stage: featuremgmt.FeatureStageUnknown,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Stage: featuremgmt.FeatureStageExperimental,
|
||||
}, {
|
||||
Name: "toggle3",
|
||||
Stage: featuremgmt.FeatureStagePrivatePreview,
|
||||
}, {
|
||||
Name: "toggle4",
|
||||
Stage: featuremgmt.FeatureStagePublicPreview,
|
||||
}, {
|
||||
Name: "toggle5",
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle6",
|
||||
Stage: featuremgmt.FeatureStageDeprecated,
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
|
||||
result := runGetScenario(t, features, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
|
||||
assert.Len(t, result, 3)
|
||||
|
||||
_, ok := findResult(t, result, "toggle1")
|
||||
assert.False(t, ok)
|
||||
_, ok = findResult(t, result, "toggle2")
|
||||
assert.False(t, ok)
|
||||
_, ok = findResult(t, result, "toggle3")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("only public preview and GA are writeable by default", func(t *testing.T) {
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateControllerUrl: "bogus",
|
||||
}
|
||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
||||
assert.Len(t, result, 3)
|
||||
|
||||
t4, ok := findResult(t, result, "toggle4")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t4.ReadOnly)
|
||||
t5, ok := findResult(t, result, "toggle5")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, t5.ReadOnly)
|
||||
t6, ok := findResult(t, result, "toggle6")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, t6.ReadOnly)
|
||||
})
|
||||
|
||||
t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
|
||||
settings := setting.FeatureMgmtSettings{
|
||||
AllowEditing: false,
|
||||
UpdateControllerUrl: "",
|
||||
}
|
||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
||||
assert.Len(t, result, 3)
|
||||
|
||||
t4, ok := findResult(t, result, "toggle4")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t4.ReadOnly)
|
||||
t5, ok := findResult(t, result, "toggle5")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t5.ReadOnly)
|
||||
t6, ok := findResult(t, result, "toggle6")
|
||||
assert.True(t, ok)
|
||||
assert.True(t, t6.ReadOnly)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetFeatureToggles(t *testing.T) {
|
||||
writePermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementWrite}}
|
||||
|
||||
t.Run("fails without adequate permissions", func(t *testing.T) {
|
||||
res := runSetScenario(t, nil, nil, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
})
|
||||
|
||||
t.Run("fails when toggle editing is not enabled", func(t *testing.T) {
|
||||
res := runSetScenario(t, nil, nil, setting.FeatureMgmtSettings{}, writePermissions, http.StatusForbidden)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, "feature toggles are read-only", p["message"])
|
||||
})
|
||||
|
||||
t.Run("fails when update toggle url is not set", func(t *testing.T) {
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
}
|
||||
res := runSetScenario(t, nil, nil, s, writePermissions, http.StatusInternalServerError)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, "feature toggles service is misconfigured", p["message"])
|
||||
})
|
||||
|
||||
t.Run("fails with non-existent toggle", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: "toggle1",
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
|
||||
updates := []featuremgmt.FeatureToggleDTO{
|
||||
{
|
||||
Name: "toggle3",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateControllerUrl: "random",
|
||||
}
|
||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, "invalid toggle passed in", p["message"])
|
||||
})
|
||||
|
||||
t.Run("fails with read-only toggles", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStagePublicPreview,
|
||||
}, {
|
||||
Name: "toggle3",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
},
|
||||
}
|
||||
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateControllerUrl: "random",
|
||||
ReadOnlyToggles: map[string]struct{}{
|
||||
"toggle3": {},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("because it is the feature toggle admin page toggle", func(t *testing.T) {
|
||||
updates := []featuremgmt.FeatureToggleDTO{
|
||||
{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, fmt.Sprintf("invalid toggle passed in: %s", featuremgmt.FlagFeatureToggleAdminPage), p["error"])
|
||||
})
|
||||
|
||||
t.Run("because it is not GA or Deprecated", func(t *testing.T) {
|
||||
updates := []featuremgmt.FeatureToggleDTO{
|
||||
{
|
||||
Name: "toggle2",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, "invalid toggle passed in: toggle2", p["error"])
|
||||
})
|
||||
|
||||
t.Run("because it is configured to be read-only", func(t *testing.T) {
|
||||
updates := []featuremgmt.FeatureToggleDTO{
|
||||
{
|
||||
Name: "toggle3",
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, "invalid toggle passed in: toggle3", p["error"])
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("succeeds with all conditions met", func(t *testing.T) {
|
||||
features := []*featuremgmt.FeatureFlag{
|
||||
{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle2",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStagePublicPreview,
|
||||
}, {
|
||||
Name: "toggle3",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle4",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}, {
|
||||
Name: "toggle5",
|
||||
Enabled: false,
|
||||
Stage: featuremgmt.FeatureStageDeprecated,
|
||||
},
|
||||
}
|
||||
|
||||
s := setting.FeatureMgmtSettings{
|
||||
AllowEditing: true,
|
||||
UpdateControllerUrl: "random",
|
||||
ReadOnlyToggles: map[string]struct{}{
|
||||
"toggle3": {},
|
||||
},
|
||||
}
|
||||
|
||||
updates := []featuremgmt.FeatureToggleDTO{
|
||||
{
|
||||
Name: "toggle4",
|
||||
Enabled: true,
|
||||
}, {
|
||||
Name: "toggle5",
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
// TODO: check for success status after the handler is fully implemented
|
||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusNotImplemented)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
p := readBody(t, res.Body)
|
||||
assert.Equal(t, "UpdateFeatureToggle is unimplemented", p["message"])
|
||||
})
|
||||
}
|
||||
|
||||
func findResult(t *testing.T, result []featuremgmt.FeatureToggleDTO, name string) (featuremgmt.FeatureToggleDTO, bool) {
|
||||
t.Helper()
|
||||
|
||||
for _, t := range result {
|
||||
if t.Name == name {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return featuremgmt.FeatureToggleDTO{}, false
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, rc io.ReadCloser) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
b, err := io.ReadAll(rc)
|
||||
require.NoError(t, err)
|
||||
payload := map[string]interface{}{}
|
||||
require.NoError(t, json.Unmarshal(b, &payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func runGetScenario(
|
||||
t *testing.T,
|
||||
features []*featuremgmt.FeatureFlag,
|
||||
settings setting.FeatureMgmtSettings,
|
||||
permissions []accesscontrol.Permission,
|
||||
expectedCode int,
|
||||
) []featuremgmt.FeatureToggleDTO {
|
||||
// Set up server and send request
|
||||
cfg := setting.NewCfg()
|
||||
cfg.FeatureManagement = settings
|
||||
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.Cfg = cfg
|
||||
hs.Features = featuremgmt.WithFeatureFlags(append([]*featuremgmt.FeatureFlag{{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}}, features...))
|
||||
hs.orgService = orgtest.NewOrgServiceFake()
|
||||
hs.userService = &usertest.FakeUserService{
|
||||
ExpectedUser: &user.User{ID: 1},
|
||||
}
|
||||
hs.log = log.New("test")
|
||||
})
|
||||
req := webtest.RequestWithSignedInUser(server.NewGetRequest("/api/featuremgmt"), userWithPermissions(1, permissions))
|
||||
res, err := server.SendJSON(req)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
// Do some general checks for every request
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedCode, res.StatusCode)
|
||||
if res.StatusCode >= 400 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []featuremgmt.FeatureToggleDTO
|
||||
err = json.NewDecoder(res.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
for i := 0; i < len(result); {
|
||||
ft := result[i]
|
||||
// Always make sure admin page toggle is read-only, then remove it to make assertions easier
|
||||
if ft.Name == featuremgmt.FlagFeatureToggleAdminPage {
|
||||
assert.True(t, ft.ReadOnly)
|
||||
result = append(result[:i], result[i+1:]...)
|
||||
continue
|
||||
}
|
||||
|
||||
// Make sure toggles explicitly marked "hidden" by config are hidden
|
||||
if _, ok := cfg.FeatureManagement.HiddenToggles[ft.Name]; ok {
|
||||
t.Fail()
|
||||
}
|
||||
|
||||
// Make sure toggles explicitly marked "read only" by config are read only
|
||||
if _, ok := cfg.FeatureManagement.ReadOnlyToggles[ft.Name]; ok {
|
||||
assert.True(t, ft.ReadOnly)
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func runSetScenario(
|
||||
t *testing.T,
|
||||
serverFeatures []*featuremgmt.FeatureFlag,
|
||||
updateFeatures []featuremgmt.FeatureToggleDTO,
|
||||
settings setting.FeatureMgmtSettings,
|
||||
permissions []accesscontrol.Permission,
|
||||
expectedCode int,
|
||||
) *http.Response {
|
||||
// Set up server and send request
|
||||
cfg := setting.NewCfg()
|
||||
cfg.FeatureManagement = settings
|
||||
|
||||
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||
hs.Cfg = cfg
|
||||
hs.Features = featuremgmt.WithFeatureFlags(append([]*featuremgmt.FeatureFlag{{
|
||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||
Enabled: true,
|
||||
Stage: featuremgmt.FeatureStageGeneralAvailability,
|
||||
}}, serverFeatures...))
|
||||
hs.orgService = orgtest.NewOrgServiceFake()
|
||||
hs.userService = &usertest.FakeUserService{
|
||||
ExpectedUser: &user.User{ID: 1},
|
||||
}
|
||||
hs.log = log.New("test")
|
||||
})
|
||||
|
||||
cmd := featuremgmt.UpdateFeatureTogglesCommand{
|
||||
FeatureToggles: updateFeatures,
|
||||
}
|
||||
b, err := json.Marshal(cmd)
|
||||
require.NoError(t, err)
|
||||
req := webtest.RequestWithSignedInUser(server.NewPostRequest("/api/featuremgmt", bytes.NewReader(b)), userWithPermissions(1, permissions))
|
||||
res, err := server.SendJSON(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, res)
|
||||
require.Equal(t, expectedCode, res.StatusCode)
|
||||
|
||||
return res
|
||||
}
|
||||
|
@ -460,7 +460,8 @@ const (
|
||||
ActionAlertingProvisioningWrite = "alert.provisioning:write"
|
||||
|
||||
// Feature Management actions
|
||||
ActionFeatureManagementRead = "featuremgmt.read"
|
||||
ActionFeatureManagementRead = "featuremgmt.read"
|
||||
ActionFeatureManagementWrite = "featuremgmt.write"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -148,6 +148,17 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
|
||||
return v
|
||||
}
|
||||
|
||||
// Check to see if a feature toggle exists by name
|
||||
func (fm *FeatureManager) LookupFlag(name string) (FeatureFlag, bool) {
|
||||
f, ok := fm.flags[name]
|
||||
if !ok {
|
||||
return FeatureFlag{}, false
|
||||
}
|
||||
return *f, true
|
||||
}
|
||||
|
||||
// ############# Test Functions #############
|
||||
|
||||
// WithFeatures is used to define feature toggles for testing.
|
||||
// The arguments are a list of strings that are optionally followed by a boolean value for example:
|
||||
// WithFeatures([]interface{}{"my_feature", "other_feature"}) or WithFeatures([]interface{}{"my_feature", true})
|
||||
@ -174,3 +185,22 @@ func WithFeatures(spec ...interface{}) *FeatureManager {
|
||||
|
||||
return &FeatureManager{enabled: enabled, flags: features}
|
||||
}
|
||||
|
||||
// WithFeatureFlags is used to define feature toggles for testing.
|
||||
// It should be used when your test feature toggles require metadata beyond `Name` and `Enabled`.
|
||||
// You should provide a feature toggle Name at a minimum.
|
||||
func WithFeatureFlags(flags []*FeatureFlag) *FeatureManager {
|
||||
count := len(flags)
|
||||
features := make(map[string]*FeatureFlag, count)
|
||||
enabled := make(map[string]bool, count)
|
||||
|
||||
for _, f := range flags {
|
||||
if f.Name == "" {
|
||||
continue
|
||||
}
|
||||
features[f.Name] = f
|
||||
enabled[f.Name] = f.Enabled
|
||||
}
|
||||
|
||||
return &FeatureManager{enabled: enabled, flags: features}
|
||||
}
|
||||
|
@ -114,6 +114,16 @@ type FeatureFlag struct {
|
||||
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"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateFeatureTogglesCommand struct {
|
||||
FeatureToggles []FeatureToggleDTO `json:"featureToggles"`
|
||||
}
|
||||
|
||||
type FeatureToggleDTO struct {
|
||||
Name string `json:"name" binding:"Required"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ReadOnly bool `json:"readOnly,omitempty"`
|
||||
}
|
@ -5,8 +5,10 @@ import (
|
||||
)
|
||||
|
||||
type FeatureMgmtSettings struct {
|
||||
HiddenToggles map[string]struct{}
|
||||
ReadOnlyToggles map[string]struct{}
|
||||
HiddenToggles map[string]struct{}
|
||||
ReadOnlyToggles map[string]struct{}
|
||||
AllowEditing bool
|
||||
UpdateControllerUrl string
|
||||
}
|
||||
|
||||
func (cfg *Cfg) readFeatureManagementConfig() {
|
||||
@ -29,4 +31,6 @@ func (cfg *Cfg) readFeatureManagementConfig() {
|
||||
|
||||
cfg.FeatureManagement.HiddenToggles = hiddenToggles
|
||||
cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles
|
||||
cfg.FeatureManagement.AllowEditing = cfg.SectionWithEnvOverrides("feature_management").Key("allow_editing").MustBool(false)
|
||||
cfg.FeatureManagement.UpdateControllerUrl = cfg.SectionWithEnvOverrides("feature_management").Key("update_controller_url").MustString("")
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user