Feature Toggles: Switch feature toggle admin page over to k8s API (#80854)

* add handling for legacy and k8s apis to frontend

* use backend srv directly not redux

* add unit test to make sure the correct apis are being called

* require api server flag

* fix feature toggle name

* ensure both pages work correctly

* make consistent with legacy api

* implement webhook update

* fix unit test

* remove old apis and update

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Michael Mandrus
2024-02-01 23:52:02 -05:00
committed by GitHub
co-authored by Ryan McKinley
parent 9c9e5e68c8
commit 7464ea4346
17 changed files with 348 additions and 775 deletions
-8
View File
@@ -418,14 +418,6 @@ func (hs *HTTPServer) registerRoutes() {
pluginRoute.Get("/:pluginId/metrics", reqOrgAdmin, routing.Wrap(hs.CollectPluginMetrics))
})
if hs.Features.IsEnabledGlobally(featuremgmt.FlagFeatureToggleAdminPage) {
apiRoute.Group("/featuremgmt", func(featuremgmtRoute routing.RouteRegister) {
featuremgmtRoute.Get("/state", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureMgmtState)
featuremgmtRoute.Get("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementRead)), hs.GetFeatureToggles)
featuremgmtRoute.Post("/", authorize(ac.EvalPermission(ac.ActionFeatureManagementWrite)), hs.UpdateFeatureToggle)
})
}
apiRoute.Get("/frontend/settings/", hs.GetFrontendSettings)
apiRoute.Get("/frontend/assets", hs.GetFrontendAssets)
-132
View File
@@ -1,132 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/log"
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 {
// object being returned
dtos := make([]featuremgmt.FeatureToggleDTO, 0)
// loop through features an add features that should be visible to dtos
for _, ft := range hs.featureManager.GetFlags() {
flag := ft.Name
if hs.featureManager.IsHiddenFromAdminPage(flag, false) {
continue
}
dto := featuremgmt.FeatureToggleDTO{
Name: flag,
Description: ft.Description,
Enabled: hs.featureManager.IsEnabled(ctx.Req.Context(), flag),
ReadOnly: !hs.featureManager.IsEditableFromAdminPage(flag),
}
dtos = append(dtos, dto)
sort.Slice(dtos, func(i, j int) bool {
return dtos[i].Name < dtos[j].Name
})
}
return response.JSON(http.StatusOK, dtos)
}
func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response.Response {
featureMgmtCfg := hs.featureManager.Settings
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.UpdateWebhook == "" {
return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_webhook is not set"))
}
cmd := featuremgmt.UpdateFeatureTogglesCommand{}
if err := web.Bind(ctx.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
payload := UpdatePayload{
FeatureToggles: make(map[string]string, len(cmd.FeatureToggles)),
User: ctx.SignedInUser.Email,
}
for _, t := range cmd.FeatureToggles {
// make sure flag exists, and only continue if flag is writeable
if hs.featureManager.IsEditableFromAdminPage(t.Name) {
hs.log.Info("UpdateFeatureToggle: updating toggle", "toggle_name", t.Name, "enabled", t.Enabled, "username", ctx.SignedInUser.Login)
payload.FeatureToggles[t.Name] = strconv.FormatBool(t.Enabled)
} 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))
}
}
err := sendWebhookUpdate(featureMgmtCfg, payload, hs.log)
if err != nil {
hs.log.Error("UpdateFeatureToggle: Failed to perform webhook request", "error", err)
return response.Respond(http.StatusBadRequest, "Failed to perform webhook request")
}
hs.featureManager.SetRestartRequired()
return response.Respond(http.StatusOK, "feature toggles updated successfully")
}
func (hs *HTTPServer) GetFeatureMgmtState(ctx *contextmodel.ReqContext) response.Response {
fmState := hs.featureManager.GetState()
return response.Respond(http.StatusOK, fmState)
}
type UpdatePayload struct {
FeatureToggles map[string]string `json:"feature_toggles"`
User string `json:"user"`
}
func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload UpdatePayload, logger log.Logger) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode >= http.StatusBadRequest {
if body, err := io.ReadAll(resp.Body); err != nil {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body))
} else {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err)
}
}
return nil
}
-491
View File
@@ -1,491 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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"
"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"
)
func TestGetFeatureToggles(t *testing.T) {
readPermissions := []accesscontrol.Permission{{Action: accesscontrol.ActionFeatureManagementRead}}
t.Run("should not be able to get feature toggles without permissions", func(t *testing.T) {
result := runGetScenario(t, []*featuremgmt.FeatureFlag{}, []string{}, setting.FeatureMgmtSettings{}, []accesscontrol.Permission{}, http.StatusForbidden)
assert.Len(t, result, 0)
})
t.Run("should be able to get feature toggles with correct permissions", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
result := runGetScenario(t, features, disabled, 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)
})
t.Run("toggles hidden by config are not present in the response", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: "toggle1",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
settings := setting.FeatureMgmtSettings{
HiddenToggles: map[string]struct{}{"toggle1": {}},
}
result := runGetScenario(t, features, []string{}, 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",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
settings := setting.FeatureMgmtSettings{
HiddenToggles: map[string]struct{}{"toggle1": {}},
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
AllowEditing: true,
UpdateWebhook: "bogus",
}
result := runGetScenario(t, features, disabled, 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,
AllowSelfServe: true,
}, {
Name: "toggle5",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: true,
}, {
Name: "toggle6",
Stage: featuremgmt.FeatureStageDeprecated,
AllowSelfServe: true,
}, {
Name: "toggle7",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: false,
},
}
t.Run("unknown, experimental, and private preview toggles are hidden by default", func(t *testing.T) {
result := runGetScenario(t, features, []string{}, setting.FeatureMgmtSettings{}, readPermissions, http.StatusOK)
assert.Len(t, result, 4)
_, 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 with AllowSelfServe are writeable", func(t *testing.T) {
settings := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "bogus",
}
result := runGetScenario(t, features, []string{}, settings, readPermissions, http.StatusOK)
assert.Len(t, result, 4)
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,
UpdateWebhook: "",
}
result := runGetScenario(t, features, []string{}, settings, readPermissions, http.StatusOK)
assert.Len(t, result, 4)
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, []string{}, 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, []string{}, 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, []string{}, 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",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2"}
updates := []featuremgmt.FeatureToggleDTO{
{
Name: "toggle3",
Enabled: true,
},
}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
}
res := runSetScenario(t, features, disabled, 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,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStagePublicPreview,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStageGeneralAvailability,
},
}
disabled := []string{"toggle2", "toggle3"}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "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, disabled, 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("because it is not GA or Deprecated", func(t *testing.T) {
updates := []featuremgmt.FeatureToggleDTO{
{
Name: "toggle2",
Enabled: true,
},
}
res := runSetScenario(t, features, disabled, 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("because it is configured to be read-only", func(t *testing.T) {
updates := []featuremgmt.FeatureToggleDTO{
{
Name: "toggle3",
Enabled: true,
},
}
res := runSetScenario(t, features, disabled, 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("when all conditions met", func(t *testing.T) {
features := []*featuremgmt.FeatureFlag{
{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle2",
Stage: featuremgmt.FeatureStagePublicPreview,
}, {
Name: "toggle3",
Stage: featuremgmt.FeatureStageGeneralAvailability,
}, {
Name: "toggle4",
Stage: featuremgmt.FeatureStageGeneralAvailability,
AllowSelfServe: true,
}, {
Name: "toggle5",
Stage: featuremgmt.FeatureStageDeprecated,
AllowSelfServe: true,
},
}
disabled := []string{"toggle2", "toggle3", "toggle4", "toggle5"}
s := setting.FeatureMgmtSettings{
AllowEditing: true,
UpdateWebhook: "random",
UpdateWebhookToken: "token",
ReadOnlyToggles: map[string]struct{}{
"toggle3": {},
},
}
updates := []featuremgmt.FeatureToggleDTO{
{
Name: "toggle4",
Enabled: true,
}, {
Name: "toggle5",
Enabled: false,
},
}
t.Run("fail when webhook request is not successful", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusBadRequest)
defer func() { require.NoError(t, res.Body.Close()) }()
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("succeed when webhook request is successul", func(t *testing.T) {
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer "+s.UpdateWebhookToken, r.Header.Get("Authorization"))
var req UpdatePayload
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, "true", req.FeatureToggles["toggle4"])
assert.Equal(t, "false", req.FeatureToggles["toggle5"])
w.WriteHeader(http.StatusOK)
}))
defer webhookServer.Close()
s.UpdateWebhook = webhookServer.URL
res := runSetScenario(t, features, disabled, updates, s, writePermissions, http.StatusOK)
defer func() { require.NoError(t, res.Body.Close()) }()
assert.Equal(t, http.StatusOK, res.StatusCode)
})
})
}
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]any {
t.Helper()
b, err := io.ReadAll(rc)
require.NoError(t, err)
payload := map[string]any{}
require.NoError(t, json.Unmarshal(b, &payload))
return payload
}
func runGetScenario(
t *testing.T,
features []*featuremgmt.FeatureFlag,
disabled []string, // the flags that are disabled
settings setting.FeatureMgmtSettings,
permissions []accesscontrol.Permission,
expectedCode int,
) []featuremgmt.FeatureToggleDTO {
// Set up server and send request
cfg := setting.NewCfg()
fm := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}}, features...), disabled...)
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.Features = fm
hs.featureManager = fm
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,
disabled []string, // the flags that are disabled
updateFeatures []featuremgmt.FeatureToggleDTO,
settings setting.FeatureMgmtSettings,
permissions []accesscontrol.Permission,
expectedCode int,
) *http.Response {
// Set up server and send request
cfg := setting.NewCfg()
features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{
Name: featuremgmt.FlagFeatureToggleAdminPage,
Stage: featuremgmt.FeatureStageGeneralAvailability,
}}, serverFeatures...), disabled...)
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.Features = features
hs.featureManager = features
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
}
+1 -3
View File
@@ -127,7 +127,6 @@ type HTTPServer struct {
RenderService rendering.Service
Cfg *setting.Cfg
Features featuremgmt.FeatureToggles
featureManager *featuremgmt.FeatureManager
SettingsProvider setting.Provider
HooksService *hooks.HooksService
navTreeService navtree.Service
@@ -235,7 +234,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
correlationsService correlations.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService,
accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService,
live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider,
contextHandler *contexthandler.ContextHandler, loggerMiddleware loggermw.Logger, features *featuremgmt.FeatureManager,
contextHandler *contexthandler.ContextHandler, loggerMiddleware loggermw.Logger, features featuremgmt.FeatureToggles,
alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service,
quotaService quota.Service, socialService social.Service, tracer tracing.Tracer,
encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService,
@@ -292,7 +291,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
QueryHistoryService: queryHistoryService,
CorrelationsService: correlationsService,
Features: features, // a read only view of the managers state
featureManager: features,
StorageService: storageService,
RemoteCacheService: remoteCache,
ProvisioningService: provisioningService,
+6 -3
View File
@@ -78,8 +78,11 @@ type FeatureTogglesList struct {
type ResolvedToggleState struct {
metav1.TypeMeta `json:",inline"`
// Can any flag be updated
Writeable bool `json:"writeable,omitempty"`
// The user is allowed to edit feature toggles on this system
AllowEditing bool `json:"allowEditing,omitempty"`
// The system has changes that require still require a restart
RestartRequired bool `json:"restartRequired,omitempty"`
// The currently enabled flags
Enabled map[string]bool `json:"enabled,omitempty"`
@@ -99,7 +102,7 @@ type ToggleStatus struct {
Enabled bool `json:"enabled"`
// Can this flag be updated
Writeable bool `json:"writeable,omitempty"`
Writeable bool `json:"writeable"`
// Where was the value configured
// eg: startup | tenant|org | user | browser
@@ -318,9 +318,16 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ResolvedToggleState(ref common.Refer
Format: "",
},
},
"writeable": {
"allowEditing": {
SchemaProps: spec.SchemaProps{
Description: "Can any flag be updated",
Description: "The user is allowed to edit feature toggles on this system",
Type: []string{"boolean"},
Format: "",
},
},
"restartRequired": {
SchemaProps: spec.SchemaProps{
Description: "The system has changes that require still require a restart",
Type: []string{"boolean"},
Format: "",
},
@@ -395,6 +402,7 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref common.ReferenceCal
"writeable": {
SchemaProps: spec.SchemaProps{
Description: "Can this flag be updated",
Default: false,
Type: []string{"boolean"},
Format: "",
},
@@ -413,7 +421,7 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref common.ReferenceCal
},
},
},
Required: []string{"name", "enabled"},
Required: []string{"name", "enabled", "writeable"},
},
},
Dependencies: []string{
+7 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/query"
"github.com/grafana/grafana/pkg/registry/apis/query/runner"
"github.com/grafana/grafana/pkg/server"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
grafanaAPIServer "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/utils"
@@ -61,8 +62,12 @@ func (o *APIServerOptions) loadAPIGroupBuilders(args []string) error {
runner.NewDummyRegistry(),
))
case "featuretoggle.grafana.app":
features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil) // none... for now
o.builders = append(o.builders, featuretoggle.NewFeatureFlagAPIBuilder(features))
o.builders = append(o.builders,
featuretoggle.NewFeatureFlagAPIBuilder(
featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil), // none... for now
&actest.FakeAccessControl{ExpectedEvaluate: false},
),
)
case "testdata.datasource.grafana.app":
ds, err := server.InitializeDataSourceAPIServer(g)
if err != nil {
+118 -24
View File
@@ -1,25 +1,37 @@
package featuretoggle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/infra/appcontext"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/util/errutil/errhttp"
"github.com/grafana/grafana/pkg/web"
)
func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureManager) v0alpha1.ResolvedToggleState {
func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState {
state := v0alpha1.ResolvedToggleState{
TypeMeta: v1.TypeMeta{
APIVersion: v0alpha1.APIVERSION,
Kind: "ResolvedToggleState",
},
Enabled: features.GetEnabled(ctx),
Enabled: b.features.GetEnabled(ctx),
RestartRequired: b.features.IsRestartRequired(),
}
// Reference to the object that defined the values
@@ -28,11 +40,11 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: "startup",
}
startup := features.GetStartupFlags()
warnings := features.GetWarning()
for _, f := range features.GetFlags() {
startup := b.features.GetStartupFlags()
warnings := b.features.GetWarning()
for _, f := range b.features.GetFlags() {
name := f.Name
if features.IsHiddenFromAdminPage(name, true) {
if b.features.IsHiddenFromAdminPage(name, false) {
continue
}
@@ -40,7 +52,7 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: name,
Description: f.Description, // simplify the UI changes
Enabled: state.Enabled[name],
Writeable: features.IsEditableFromAdminPage(name),
Writeable: b.features.IsEditableFromAdminPage(name),
Source: startupRef,
Warning: warnings[name],
}
@@ -51,55 +63,87 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup {
state.Toggles = append(state.Toggles, toggle)
}
if toggle.Writeable {
state.AllowEditing = true
}
}
// Make sure the user can actually write values
if state.AllowEditing {
state.AllowEditing = b.features.IsFeatureEditingAllowed() && b.userCanWrite(ctx, nil)
}
return state
}
func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool {
if u == nil {
u, _ = appcontext.User(ctx)
if u == nil {
return false
}
}
ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementWrite))
return ok && err == nil
}
func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
b.handlePatchCurrent(w, r)
return
}
state := getResolvedToggleState(r.Context(), b.features)
err := json.NewEncoder(w).Encode(state)
if err != nil {
w.WriteHeader(500)
}
state := b.getResolvedToggleState(r.Context())
_ = json.NewEncoder(w).Encode(state)
}
// NOTE: authz is already handled by the authorizer
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !b.features.IsFeatureEditingAllowed() {
_, _ = w.Write([]byte("Feature editing is disabled"))
errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w)
return
}
ctx := r.Context()
request := v0alpha1.ResolvedToggleState{}
err := web.Bind(r, &request)
user, err := appcontext.User(ctx)
if err != nil {
_, _ = w.Write([]byte("ERROR!!! " + err.Error()))
errhttp.Write(ctx, err, w)
return
}
if !b.userCanWrite(ctx, user) {
err = errutil.Unauthorized("featuretoggle.canNotWrite",
errutil.WithPublicMessage("missing write permission"))
errhttp.Write(ctx, err, w)
return
}
request := v0alpha1.ResolvedToggleState{}
err = web.Bind(r, &request)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if len(request.Toggles) > 0 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("can only patch the enabled"))
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can only path the enabled section"))
errhttp.Write(ctx, err, w)
return
}
changes := map[string]bool{}
changes := map[string]string{} // TODO would be nice to have this be a bool on the HG side
for k, v := range request.Enabled {
current := b.features.IsEnabled(ctx, k)
if current != v {
if !b.features.IsEditableFromAdminPage(k) {
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can not edit toggle: "+k))
errhttp.Write(ctx, err, w)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("can not edit toggle: " + k))
return
}
changes[k] = v
changes[k] = strconv.FormatBool(v)
}
}
@@ -108,5 +152,55 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
return
}
_, _ = w.Write([]byte("TODO... actually UPDATE/call webhook: "))
payload := featuremgmt.FeatureToggleWebhookPayload{
FeatureToggles: changes,
User: user.Email,
}
err = sendWebhookUpdate(b.features.Settings, payload)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
b.features.SetRestartRequired()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("feature toggles updated successfully"))
}
func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload featuremgmt.FeatureToggleWebhookPayload) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode >= http.StatusBadRequest {
if body, err := io.ReadAll(resp.Body); err != nil {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body))
} else {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err)
}
}
return nil
}
+9 -9
View File
@@ -14,6 +14,7 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
@@ -24,20 +25,19 @@ var gv = v0alpha1.SchemeGroupVersion
// This is used just so wire has something unique to return
type FeatureFlagAPIBuilder struct {
features *featuremgmt.FeatureManager
features *featuremgmt.FeatureManager
accessControl accesscontrol.AccessControl
}
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager) *FeatureFlagAPIBuilder {
return &FeatureFlagAPIBuilder{features}
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl) *FeatureFlagAPIBuilder {
return &FeatureFlagAPIBuilder{features, accessControl}
}
func RegisterAPIService(features *featuremgmt.FeatureManager,
accessControl accesscontrol.AccessControl,
apiregistration builder.APIRegistrar,
) *FeatureFlagAPIBuilder {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil // skip registration unless opting into experimental apis
}
builder := NewFeatureFlagAPIBuilder(features)
builder := NewFeatureFlagAPIBuilder(features, accessControl)
apiregistration.RegisterAPI(builder)
return builder
}
@@ -161,7 +161,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
"enable-auto-migrate": {
ExampleProps: spec3.ExampleProps{
Summary: "enable auto-migrate panels",
Description: "example descr",
Description: "enable description",
Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: true,
@@ -172,7 +172,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
"disable-auto-migrate": {
ExampleProps: spec3.ExampleProps{
Summary: "disable auto-migrate panels",
Description: "disable descr",
Description: "disable description",
Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: false,
+5 -7
View File
@@ -131,18 +131,16 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
return v
}
func (fm *FeatureManager) GetState() *FeatureManagerState {
return &FeatureManagerState{
RestartRequired: fm.restartRequired,
AllowEditing: fm.Settings.AllowEditing,
}
}
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
func (fm *FeatureManager) IsFeatureEditingAllowed() bool {
return fm.Settings.AllowEditing && fm.Settings.UpdateWebhook != ""
}
// indicate if a change has been made (not that accurate, but better than nothing)
func (fm *FeatureManager) IsRestartRequired() bool {
return fm.restartRequired
}
// Flags that can be edited
func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool {
flag, ok := fm.flags[key]
+3 -14
View File
@@ -134,18 +134,7 @@ type FeatureFlag struct {
RequiresRestart bool `json:"requiresRestart,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"`
}
type FeatureManagerState struct {
RestartRequired bool `json:"restartRequired"`
AllowEditing bool `json:"allowEditing"`
type FeatureToggleWebhookPayload struct {
FeatureToggles map[string]string `json:"feature_toggles"`
User string `json:"user"`
}