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)) 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/settings/", hs.GetFrontendSettings)
apiRoute.Get("/frontend/assets", hs.GetFrontendAssets) 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 RenderService rendering.Service
Cfg *setting.Cfg Cfg *setting.Cfg
Features featuremgmt.FeatureToggles Features featuremgmt.FeatureToggles
featureManager *featuremgmt.FeatureManager
SettingsProvider setting.Provider SettingsProvider setting.Provider
HooksService *hooks.HooksService HooksService *hooks.HooksService
navTreeService navtree.Service 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, correlationsService correlations.Service, remoteCache *remotecache.RemoteCache, provisioningService provisioning.ProvisioningService,
accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService, accessControl accesscontrol.AccessControl, dataSourceProxy *datasourceproxy.DataSourceProxyService, searchService *search.SearchService,
live *live.GrafanaLive, livePushGateway *pushhttp.Gateway, plugCtxProvider *plugincontext.Provider, 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, alertNG *ngalert.AlertNG, libraryPanelService librarypanels.Service, libraryElementService libraryelements.Service,
quotaService quota.Service, socialService social.Service, tracer tracing.Tracer, quotaService quota.Service, socialService social.Service, tracer tracing.Tracer,
encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService, encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService,
@@ -292,7 +291,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
QueryHistoryService: queryHistoryService, QueryHistoryService: queryHistoryService,
CorrelationsService: correlationsService, CorrelationsService: correlationsService,
Features: features, // a read only view of the managers state Features: features, // a read only view of the managers state
featureManager: features,
StorageService: storageService, StorageService: storageService,
RemoteCacheService: remoteCache, RemoteCacheService: remoteCache,
ProvisioningService: provisioningService, ProvisioningService: provisioningService,
+6 -3
View File
@@ -78,8 +78,11 @@ type FeatureTogglesList struct {
type ResolvedToggleState struct { type ResolvedToggleState struct {
metav1.TypeMeta `json:",inline"` metav1.TypeMeta `json:",inline"`
// Can any flag be updated // The user is allowed to edit feature toggles on this system
Writeable bool `json:"writeable,omitempty"` AllowEditing bool `json:"allowEditing,omitempty"`
// The system has changes that require still require a restart
RestartRequired bool `json:"restartRequired,omitempty"`
// The currently enabled flags // The currently enabled flags
Enabled map[string]bool `json:"enabled,omitempty"` Enabled map[string]bool `json:"enabled,omitempty"`
@@ -99,7 +102,7 @@ type ToggleStatus struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
// Can this flag be updated // Can this flag be updated
Writeable bool `json:"writeable,omitempty"` Writeable bool `json:"writeable"`
// Where was the value configured // Where was the value configured
// eg: startup | tenant|org | user | browser // eg: startup | tenant|org | user | browser
@@ -318,9 +318,16 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ResolvedToggleState(ref common.Refer
Format: "", Format: "",
}, },
}, },
"writeable": { "allowEditing": {
SchemaProps: spec.SchemaProps{ 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"}, Type: []string{"boolean"},
Format: "", Format: "",
}, },
@@ -395,6 +402,7 @@ func schema_pkg_apis_featuretoggle_v0alpha1_ToggleStatus(ref common.ReferenceCal
"writeable": { "writeable": {
SchemaProps: spec.SchemaProps{ SchemaProps: spec.SchemaProps{
Description: "Can this flag be updated", Description: "Can this flag be updated",
Default: false,
Type: []string{"boolean"}, Type: []string{"boolean"},
Format: "", 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{ 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"
"github.com/grafana/grafana/pkg/registry/apis/query/runner" "github.com/grafana/grafana/pkg/registry/apis/query/runner"
"github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/server"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
grafanaAPIServer "github.com/grafana/grafana/pkg/services/apiserver" grafanaAPIServer "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/apiserver/utils" "github.com/grafana/grafana/pkg/services/apiserver/utils"
@@ -61,8 +62,12 @@ func (o *APIServerOptions) loadAPIGroupBuilders(args []string) error {
runner.NewDummyRegistry(), runner.NewDummyRegistry(),
)) ))
case "featuretoggle.grafana.app": case "featuretoggle.grafana.app":
features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil) // none... for now o.builders = append(o.builders,
o.builders = append(o.builders, featuretoggle.NewFeatureFlagAPIBuilder(features)) featuretoggle.NewFeatureFlagAPIBuilder(
featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, nil), // none... for now
&actest.FakeAccessControl{ExpectedEvaluate: false},
),
)
case "testdata.datasource.grafana.app": case "testdata.datasource.grafana.app":
ds, err := server.InitializeDataSourceAPIServer(g) ds, err := server.InitializeDataSourceAPIServer(g)
if err != nil { if err != nil {
+118 -24
View File
@@ -1,25 +1,37 @@
package featuretoggle package featuretoggle
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io"
"net/http" "net/http"
"strconv"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1" common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/featuretoggle/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/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" "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{ state := v0alpha1.ResolvedToggleState{
TypeMeta: v1.TypeMeta{ TypeMeta: v1.TypeMeta{
APIVersion: v0alpha1.APIVERSION, APIVersion: v0alpha1.APIVERSION,
Kind: "ResolvedToggleState", Kind: "ResolvedToggleState",
}, },
Enabled: features.GetEnabled(ctx), Enabled: b.features.GetEnabled(ctx),
RestartRequired: b.features.IsRestartRequired(),
} }
// Reference to the object that defined the values // Reference to the object that defined the values
@@ -28,11 +40,11 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: "startup", Name: "startup",
} }
startup := features.GetStartupFlags() startup := b.features.GetStartupFlags()
warnings := features.GetWarning() warnings := b.features.GetWarning()
for _, f := range features.GetFlags() { for _, f := range b.features.GetFlags() {
name := f.Name name := f.Name
if features.IsHiddenFromAdminPage(name, true) { if b.features.IsHiddenFromAdminPage(name, false) {
continue continue
} }
@@ -40,7 +52,7 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: name, Name: name,
Description: f.Description, // simplify the UI changes Description: f.Description, // simplify the UI changes
Enabled: state.Enabled[name], Enabled: state.Enabled[name],
Writeable: features.IsEditableFromAdminPage(name), Writeable: b.features.IsEditableFromAdminPage(name),
Source: startupRef, Source: startupRef,
Warning: warnings[name], Warning: warnings[name],
} }
@@ -51,55 +63,87 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup { if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup {
state.Toggles = append(state.Toggles, toggle) 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 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) { func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch { if r.Method == http.MethodPatch {
b.handlePatchCurrent(w, r) b.handlePatchCurrent(w, r)
return return
} }
state := getResolvedToggleState(r.Context(), b.features) state := b.getResolvedToggleState(r.Context())
_ = json.NewEncoder(w).Encode(state)
err := json.NewEncoder(w).Encode(state)
if err != nil {
w.WriteHeader(500)
}
} }
// NOTE: authz is already handled by the authorizer // NOTE: authz is already handled by the authorizer
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) { func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !b.features.IsFeatureEditingAllowed() { if !b.features.IsFeatureEditingAllowed() {
_, _ = w.Write([]byte("Feature editing is disabled")) errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w)
return return
} }
ctx := r.Context() user, err := appcontext.User(ctx)
request := v0alpha1.ResolvedToggleState{}
err := web.Bind(r, &request)
if err != nil { 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 return
} }
if len(request.Toggles) > 0 { if len(request.Toggles) > 0 {
w.WriteHeader(http.StatusBadRequest) err = errutil.BadRequest("featuretoggle.badRequest",
_, _ = w.Write([]byte("can only patch the enabled")) errutil.WithPublicMessage("can only path the enabled section"))
errhttp.Write(ctx, err, w)
return 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 { for k, v := range request.Enabled {
current := b.features.IsEnabled(ctx, k) current := b.features.IsEnabled(ctx, k)
if current != v { if current != v {
if !b.features.IsEditableFromAdminPage(k) { 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.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("can not edit toggle: " + k))
return return
} }
changes[k] = v changes[k] = strconv.FormatBool(v)
} }
} }
@@ -108,5 +152,55 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
return 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" "k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" "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/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt" "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 // This is used just so wire has something unique to return
type FeatureFlagAPIBuilder struct { type FeatureFlagAPIBuilder struct {
features *featuremgmt.FeatureManager features *featuremgmt.FeatureManager
accessControl accesscontrol.AccessControl
} }
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager) *FeatureFlagAPIBuilder { func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl) *FeatureFlagAPIBuilder {
return &FeatureFlagAPIBuilder{features} return &FeatureFlagAPIBuilder{features, accessControl}
} }
func RegisterAPIService(features *featuremgmt.FeatureManager, func RegisterAPIService(features *featuremgmt.FeatureManager,
accessControl accesscontrol.AccessControl,
apiregistration builder.APIRegistrar, apiregistration builder.APIRegistrar,
) *FeatureFlagAPIBuilder { ) *FeatureFlagAPIBuilder {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { builder := NewFeatureFlagAPIBuilder(features, accessControl)
return nil // skip registration unless opting into experimental apis
}
builder := NewFeatureFlagAPIBuilder(features)
apiregistration.RegisterAPI(builder) apiregistration.RegisterAPI(builder)
return builder return builder
} }
@@ -161,7 +161,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
"enable-auto-migrate": { "enable-auto-migrate": {
ExampleProps: spec3.ExampleProps{ ExampleProps: spec3.ExampleProps{
Summary: "enable auto-migrate panels", Summary: "enable auto-migrate panels",
Description: "example descr", Description: "enable description",
Value: &v0alpha1.ResolvedToggleState{ Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{ Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: true, featuremgmt.FlagAutoMigrateOldPanels: true,
@@ -172,7 +172,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
"disable-auto-migrate": { "disable-auto-migrate": {
ExampleProps: spec3.ExampleProps{ ExampleProps: spec3.ExampleProps{
Summary: "disable auto-migrate panels", Summary: "disable auto-migrate panels",
Description: "disable descr", Description: "disable description",
Value: &v0alpha1.ResolvedToggleState{ Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{ Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: false, featuremgmt.FlagAutoMigrateOldPanels: false,
+5 -7
View File
@@ -131,18 +131,16 @@ func (fm *FeatureManager) GetFlags() []FeatureFlag {
return v 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 // isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
func (fm *FeatureManager) IsFeatureEditingAllowed() bool { func (fm *FeatureManager) IsFeatureEditingAllowed() bool {
return fm.Settings.AllowEditing && fm.Settings.UpdateWebhook != "" 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 // Flags that can be edited
func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool { func (fm *FeatureManager) IsEditableFromAdminPage(key string) bool {
flag, ok := fm.flags[key] flag, ok := fm.flags[key]
+3 -14
View File
@@ -134,18 +134,7 @@ type FeatureFlag struct {
RequiresRestart bool `json:"requiresRestart,omitempty"` RequiresRestart bool `json:"requiresRestart,omitempty"`
} }
type UpdateFeatureTogglesCommand struct { type FeatureToggleWebhookPayload struct {
FeatureToggles []FeatureToggleDTO `json:"featureToggles"` FeatureToggles map[string]string `json:"feature_toggles"`
} User string `json:"user"`
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"`
} }
-2
View File
@@ -2,7 +2,6 @@ import { ReducersMapObject } from '@reduxjs/toolkit';
import { AnyAction, combineReducers } from 'redux'; import { AnyAction, combineReducers } from 'redux';
import sharedReducers from 'app/core/reducers'; import sharedReducers from 'app/core/reducers';
import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI';
import ldapReducers from 'app/features/admin/state/reducers'; import ldapReducers from 'app/features/admin/state/reducers';
import alertingReducers from 'app/features/alerting/state/reducers'; import alertingReducers from 'app/features/alerting/state/reducers';
import apiKeysReducers from 'app/features/api-keys/state/reducers'; import apiKeysReducers from 'app/features/api-keys/state/reducers';
@@ -56,7 +55,6 @@ const rootReducers = {
[alertingApi.reducerPath]: alertingApi.reducer, [alertingApi.reducerPath]: alertingApi.reducer,
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
[togglesApi.reducerPath]: togglesApi.reducer,
}; };
const addedReducers = {}; const addedReducers = {};
@@ -0,0 +1,102 @@
import { BackendSrvRequest, config } from '@grafana/runtime';
import { getTogglesAPI } from './AdminFeatureTogglesAPI';
// implements @grafana/runtime/BackendSrv
class MockSrv {
constructor() {
this.apiCalls = [];
}
apiCalls: Array<{
url: string;
method: string;
}>;
async get(
url: string,
params?: BackendSrvRequest['params'],
requestId?: BackendSrvRequest['requestId'],
options?: Partial<BackendSrvRequest>
) {
this.apiCalls.push({
url: url,
method: 'get',
});
if (config.featureToggles.kubernetesFeatureToggles && url.indexOf('current') > -1) {
return await { toggles: [] };
}
return await {};
}
async post(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) {
this.apiCalls.push({
url: url,
method: 'post',
});
return await {};
}
async patch(url: string, data: unknown, options?: Partial<BackendSrvRequest>) {
this.apiCalls.push({
url: url,
method: 'patch',
});
return await {};
}
// these aren't needed for this test
async put(url: string, data: unknown, options?: Partial<BackendSrvRequest>) {
return await {};
}
async delete(url: string, data?: unknown, options?: Partial<BackendSrvRequest>) {
return await {};
}
}
const testBackendSrv = new MockSrv();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => testBackendSrv,
config: {
featureToggles: {
kubernetesFeatureToggles: false,
grafanaAPIServerWithExperimentalAPIs: false,
},
},
}));
describe('AdminFeatureTogglesApi', () => {
beforeEach(() => {
jest.clearAllMocks();
testBackendSrv.apiCalls.length = 0;
});
const originalToggles = { ...config.featureToggles };
afterAll(() => {
config.featureToggles = originalToggles;
});
it('uses the k8s api when the k8s toggles are on', async () => {
config.featureToggles.kubernetesFeatureToggles = true;
config.featureToggles.grafanaAPIServerWithExperimentalAPIs = true;
const togglesApi = getTogglesAPI();
await togglesApi.getFeatureToggles();
await togglesApi.updateFeatureToggles([]);
const expected = [
{
method: 'get',
url: '/apis/featuretoggle.grafana.app/v0alpha1/current',
},
{
method: 'patch',
url: '/apis/featuretoggle.grafana.app/v0alpha1/current',
},
];
expect(testBackendSrv.apiCalls).toEqual(expect.arrayContaining(expected));
});
});
@@ -1,62 +1,74 @@
import { createApi, BaseQueryFn } from '@reduxjs/toolkit/query/react';
import { lastValueFrom } from 'rxjs';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
type QueryArgs = { export type FeatureToggle = {
url: string;
method?: string;
body?: { featureToggles: FeatureToggle[] };
};
const backendSrvBaseQuery =
({ baseUrl }: { baseUrl: string }): BaseQueryFn<QueryArgs> =>
async ({ url, method = 'GET', body }) => {
try {
const { data } = await lastValueFrom(
getBackendSrv().fetch({
url: baseUrl + url,
method,
data: body,
})
);
return { data };
} catch (error) {
return { error };
}
};
export const togglesApi = createApi({
reducerPath: 'togglesApi',
baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getManagerState: builder.query<FeatureMgmtState, void>({
query: () => ({ url: '/featuremgmt/state' }),
}),
getFeatureToggles: builder.query<FeatureToggle[], void>({
query: () => ({ url: '/featuremgmt' }),
}),
updateFeatureToggles: builder.mutation<void, FeatureToggle[]>({
query: (updatedToggles) => ({
url: '/featuremgmt',
method: 'POST',
body: { featureToggles: updatedToggles },
}),
}),
}),
});
type FeatureToggle = {
name: string; name: string;
description?: string; description?: string;
enabled: boolean; enabled: boolean;
readOnly?: boolean; readOnly?: boolean;
hidden?: boolean;
}; };
type FeatureMgmtState = { export type CurrentTogglesState = {
restartRequired: boolean; restartRequired: boolean;
allowEditing: boolean; allowEditing: boolean;
toggles: FeatureToggle[];
}; };
export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi; interface ResolvedToggleState {
export type { FeatureToggle, FeatureMgmtState }; kind: 'ResolvedToggleState';
restartRequired?: boolean;
allowEditing?: boolean;
toggles?: K8sToggleSpec[]; // not used in patch
enabled: { [key: string]: boolean };
}
interface K8sToggleSpec {
name: string;
description: string;
enabled: boolean;
writeable: boolean;
source: K8sToggleSource;
}
interface K8sToggleSource {
namespace: string;
name: string;
}
interface FeatureTogglesAPI {
getFeatureToggles(): Promise<CurrentTogglesState>;
updateFeatureToggles(toggles: FeatureToggle[]): Promise<void>;
}
class K8sAPI implements FeatureTogglesAPI {
baseURL = '/apis/featuretoggle.grafana.app/v0alpha1';
async getFeatureToggles(): Promise<CurrentTogglesState> {
const current = await getBackendSrv().get<ResolvedToggleState>(this.baseURL + '/current');
return {
restartRequired: Boolean(current.restartRequired),
allowEditing: Boolean(current.allowEditing),
toggles: current.toggles!.map((t) => ({
name: t.name,
description: t.description!,
enabled: t.enabled,
readOnly: !Boolean(t.writeable),
hidden: false, // only return visible things
})),
};
}
updateFeatureToggles(toggles: FeatureToggle[]): Promise<void> {
const patchBody: ResolvedToggleState = {
kind: 'ResolvedToggleState',
enabled: {},
};
toggles.forEach((t) => {
patchBody.enabled[t.name] = t.enabled;
});
return getBackendSrv().patch(this.baseURL + '/current', patchBody);
}
}
export const getTogglesAPI = (): FeatureTogglesAPI => {
return new K8sAPI();
};
@@ -1,26 +1,24 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Icon } from '@grafana/ui'; import { useStyles2, Icon } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { useGetFeatureTogglesQuery, useGetManagerStateQuery } from './AdminFeatureTogglesAPI'; import { getTogglesAPI } from './AdminFeatureTogglesAPI';
import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable'; import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable';
export default function AdminFeatureTogglesPage() { export default function AdminFeatureTogglesPage() {
const { data: featureToggles, isLoading, isError } = useGetFeatureTogglesQuery(); const [reload] = useState(1);
const { data: featureMgmtState } = useGetManagerStateQuery(); const togglesApi = getTogglesAPI();
const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]);
const [updateSuccessful, setUpdateSuccessful] = useState(false); const [updateSuccessful, setUpdateSuccessful] = useState(false);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const getErrorMessage = () => {
return 'Error fetching feature toggles';
};
const handleUpdateSuccess = () => { const handleUpdateSuccess = () => {
setUpdateSuccessful(true); setUpdateSuccessful(true);
// setReload(reload+1); << would trigger updating the server state!
}; };
const EditingAlert = () => { const EditingAlert = () => {
@@ -30,7 +28,7 @@ export default function AdminFeatureTogglesPage() {
<Icon name="exclamation-triangle" /> <Icon name="exclamation-triangle" />
</div> </div>
<span className={styles.message}> <span className={styles.message}>
{featureMgmtState?.restartRequired || updateSuccessful {featureState.value?.restartRequired || updateSuccessful
? 'A restart is pending for your Grafana instance to apply the latest feature toggle changes' ? 'A restart is pending for your Grafana instance to apply the latest feature toggle changes'
: 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'} : 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'}
</span> </span>
@@ -54,15 +52,16 @@ export default function AdminFeatureTogglesPage() {
return ( return (
<Page navId="feature-toggles" subTitle={subTitle}> <Page navId="feature-toggles" subTitle={subTitle}>
<Page.Contents> <Page.Contents isLoading={featureState.loading}>
<> <>
{isError && getErrorMessage()} {featureState.error}
{isLoading && 'Fetching feature toggles'} {featureState.loading && 'Fetching feature toggles'}
{featureMgmtState?.allowEditing && <EditingAlert />}
{featureToggles && ( {featureState.value?.restartRequired && <EditingAlert />}
{featureState.value && (
<AdminFeatureTogglesTable <AdminFeatureTogglesTable
featureToggles={featureToggles} featureToggles={featureState.value.toggles}
allowEditing={featureMgmtState?.allowEditing || false} allowEditing={featureState.value.allowEditing || false}
onUpdateSuccess={handleUpdateSuccess} onUpdateSuccess={handleUpdateSuccess}
/> />
)} )}
@@ -2,7 +2,7 @@ import React, { useState, useRef } from 'react';
import { Switch, InteractiveTable, Tooltip, type CellProps, Button, type SortByFn } from '@grafana/ui'; import { Switch, InteractiveTable, Tooltip, type CellProps, Button, type SortByFn } from '@grafana/ui';
import { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI'; import { FeatureToggle, getTogglesAPI } from './AdminFeatureTogglesAPI';
interface Props { interface Props {
featureToggles: FeatureToggle[]; featureToggles: FeatureToggle[];
@@ -30,10 +30,12 @@ const sortByEnabled: SortByFn<FeatureToggle> = (a, b) => {
}; };
export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdateSuccess }: Props) { export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdateSuccess }: Props) {
// sort manually, doesn't look like it can be automatically done in the table
featureToggles.sort((a, b) => a.name.localeCompare(b.name));
const serverToggles = useRef<FeatureToggle[]>(featureToggles); const serverToggles = useRef<FeatureToggle[]>(featureToggles);
const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles); const [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const togglesApi = getTogglesAPI();
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => { const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
const updatedToggle = { ...toggle, enabled: newValue }; const updatedToggle = { ...toggle, enabled: newValue };
@@ -47,12 +49,10 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat
setIsSaving(true); setIsSaving(true);
try { try {
const modifiedToggles = getModifiedToggles(); const modifiedToggles = getModifiedToggles();
const resp = await updateFeatureToggles(modifiedToggles); await togglesApi.updateFeatureToggles(modifiedToggles);
if (!('error' in resp)) { // Pretend the values came from a new request
// server toggles successfully updated serverToggles.current = [...localToggles];
serverToggles.current = [...localToggles]; onUpdateSuccess(); // should trigger a new get
onUpdateSuccess();
}
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
+1 -3
View File
@@ -1,7 +1,6 @@
import { configureStore as reduxConfigureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { configureStore as reduxConfigureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query'; import { setupListeners } from '@reduxjs/toolkit/query';
import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI';
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi'; import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { StoreState } from 'app/types/store'; import { StoreState } from 'app/types/store';
@@ -29,8 +28,7 @@ export function configureStore(initialState?: Partial<StoreState>) {
listenerMiddleware.middleware, listenerMiddleware.middleware,
alertingApi.middleware, alertingApi.middleware,
publicDashboardApi.middleware, publicDashboardApi.middleware,
browseDashboardsAPI.middleware, browseDashboardsAPI.middleware
togglesApi.middleware
), ),
devTools: process.env.NODE_ENV !== 'production', devTools: process.env.NODE_ENV !== 'production',
preloadedState: { preloadedState: {