mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
co-authored by
Ryan McKinley
parent
9c9e5e68c8
commit
7464ea4346
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -25,19 +26,18 @@ var gv = v0alpha1.SchemeGroupVersion
|
||||
// This is used just so wire has something unique to return
|
||||
type FeatureFlagAPIBuilder struct {
|
||||
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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReducersMapObject } from '@reduxjs/toolkit';
|
||||
import { AnyAction, combineReducers } from 'redux';
|
||||
|
||||
import sharedReducers from 'app/core/reducers';
|
||||
import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI';
|
||||
import ldapReducers from 'app/features/admin/state/reducers';
|
||||
import alertingReducers from 'app/features/alerting/state/reducers';
|
||||
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||
@@ -56,7 +55,6 @@ const rootReducers = {
|
||||
[alertingApi.reducerPath]: alertingApi.reducer,
|
||||
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
|
||||
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
|
||||
[togglesApi.reducerPath]: togglesApi.reducer,
|
||||
};
|
||||
|
||||
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';
|
||||
|
||||
type QueryArgs = {
|
||||
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 = {
|
||||
export type FeatureToggle = {
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
readOnly?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
type FeatureMgmtState = {
|
||||
export type CurrentTogglesState = {
|
||||
restartRequired: boolean;
|
||||
allowEditing: boolean;
|
||||
toggles: FeatureToggle[];
|
||||
};
|
||||
|
||||
export const { useGetManagerStateQuery, useGetFeatureTogglesQuery, useUpdateFeatureTogglesMutation } = togglesApi;
|
||||
export type { FeatureToggle, FeatureMgmtState };
|
||||
interface ResolvedToggleState {
|
||||
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 React, { useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { useStyles2, Icon } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { useGetFeatureTogglesQuery, useGetManagerStateQuery } from './AdminFeatureTogglesAPI';
|
||||
import { getTogglesAPI } from './AdminFeatureTogglesAPI';
|
||||
import { AdminFeatureTogglesTable } from './AdminFeatureTogglesTable';
|
||||
|
||||
export default function AdminFeatureTogglesPage() {
|
||||
const { data: featureToggles, isLoading, isError } = useGetFeatureTogglesQuery();
|
||||
const { data: featureMgmtState } = useGetManagerStateQuery();
|
||||
const [reload] = useState(1);
|
||||
const togglesApi = getTogglesAPI();
|
||||
const featureState = useAsync(() => togglesApi.getFeatureToggles(), [reload]);
|
||||
const [updateSuccessful, setUpdateSuccessful] = useState(false);
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const getErrorMessage = () => {
|
||||
return 'Error fetching feature toggles';
|
||||
};
|
||||
|
||||
const handleUpdateSuccess = () => {
|
||||
setUpdateSuccessful(true);
|
||||
// setReload(reload+1); << would trigger updating the server state!
|
||||
};
|
||||
|
||||
const EditingAlert = () => {
|
||||
@@ -30,7 +28,7 @@ export default function AdminFeatureTogglesPage() {
|
||||
<Icon name="exclamation-triangle" />
|
||||
</div>
|
||||
<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'
|
||||
: 'Saving feature toggle changes will prompt a restart of the instance, which may take a few minutes'}
|
||||
</span>
|
||||
@@ -54,15 +52,16 @@ export default function AdminFeatureTogglesPage() {
|
||||
|
||||
return (
|
||||
<Page navId="feature-toggles" subTitle={subTitle}>
|
||||
<Page.Contents>
|
||||
<Page.Contents isLoading={featureState.loading}>
|
||||
<>
|
||||
{isError && getErrorMessage()}
|
||||
{isLoading && 'Fetching feature toggles'}
|
||||
{featureMgmtState?.allowEditing && <EditingAlert />}
|
||||
{featureToggles && (
|
||||
{featureState.error}
|
||||
{featureState.loading && 'Fetching feature toggles'}
|
||||
|
||||
{featureState.value?.restartRequired && <EditingAlert />}
|
||||
{featureState.value && (
|
||||
<AdminFeatureTogglesTable
|
||||
featureToggles={featureToggles}
|
||||
allowEditing={featureMgmtState?.allowEditing || false}
|
||||
featureToggles={featureState.value.toggles}
|
||||
allowEditing={featureState.value.allowEditing || false}
|
||||
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 { type FeatureToggle, useUpdateFeatureTogglesMutation } from './AdminFeatureTogglesAPI';
|
||||
import { FeatureToggle, getTogglesAPI } from './AdminFeatureTogglesAPI';
|
||||
|
||||
interface Props {
|
||||
featureToggles: FeatureToggle[];
|
||||
@@ -30,10 +30,12 @@ const sortByEnabled: SortByFn<FeatureToggle> = (a, b) => {
|
||||
};
|
||||
|
||||
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 [localToggles, setLocalToggles] = useState<FeatureToggle[]>(featureToggles);
|
||||
const [updateFeatureToggles] = useUpdateFeatureTogglesMutation();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const togglesApi = getTogglesAPI();
|
||||
|
||||
const handleToggleChange = (toggle: FeatureToggle, newValue: boolean) => {
|
||||
const updatedToggle = { ...toggle, enabled: newValue };
|
||||
@@ -47,12 +49,10 @@ export function AdminFeatureTogglesTable({ featureToggles, allowEditing, onUpdat
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const modifiedToggles = getModifiedToggles();
|
||||
const resp = await updateFeatureToggles(modifiedToggles);
|
||||
if (!('error' in resp)) {
|
||||
// server toggles successfully updated
|
||||
await togglesApi.updateFeatureToggles(modifiedToggles);
|
||||
// Pretend the values came from a new request
|
||||
serverToggles.current = [...localToggles];
|
||||
onUpdateSuccess();
|
||||
}
|
||||
onUpdateSuccess(); // should trigger a new get
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { configureStore as reduxConfigureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { setupListeners } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { togglesApi } from 'app/features/admin/AdminFeatureTogglesAPI';
|
||||
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { StoreState } from 'app/types/store';
|
||||
@@ -29,8 +28,7 @@ export function configureStore(initialState?: Partial<StoreState>) {
|
||||
listenerMiddleware.middleware,
|
||||
alertingApi.middleware,
|
||||
publicDashboardApi.middleware,
|
||||
browseDashboardsAPI.middleware,
|
||||
togglesApi.middleware
|
||||
browseDashboardsAPI.middleware
|
||||
),
|
||||
devTools: process.env.NODE_ENV !== 'production',
|
||||
preloadedState: {
|
||||
|
||||
Reference in New Issue
Block a user