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
parent 9c9e5e68c8
commit 7464ea4346
17 changed files with 348 additions and 775 deletions

View File

@@ -1,25 +1,37 @@
package featuretoggle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
common "github.com/grafana/grafana/pkg/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
"github.com/grafana/grafana/pkg/infra/appcontext"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/errutil"
"github.com/grafana/grafana/pkg/util/errutil/errhttp"
"github.com/grafana/grafana/pkg/web"
)
func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureManager) v0alpha1.ResolvedToggleState {
func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState {
state := v0alpha1.ResolvedToggleState{
TypeMeta: v1.TypeMeta{
APIVersion: v0alpha1.APIVERSION,
Kind: "ResolvedToggleState",
},
Enabled: features.GetEnabled(ctx),
Enabled: b.features.GetEnabled(ctx),
RestartRequired: b.features.IsRestartRequired(),
}
// Reference to the object that defined the values
@@ -28,11 +40,11 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: "startup",
}
startup := features.GetStartupFlags()
warnings := features.GetWarning()
for _, f := range features.GetFlags() {
startup := b.features.GetStartupFlags()
warnings := b.features.GetWarning()
for _, f := range b.features.GetFlags() {
name := f.Name
if features.IsHiddenFromAdminPage(name, true) {
if b.features.IsHiddenFromAdminPage(name, false) {
continue
}
@@ -40,7 +52,7 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
Name: name,
Description: f.Description, // simplify the UI changes
Enabled: state.Enabled[name],
Writeable: features.IsEditableFromAdminPage(name),
Writeable: b.features.IsEditableFromAdminPage(name),
Source: startupRef,
Warning: warnings[name],
}
@@ -51,55 +63,87 @@ func getResolvedToggleState(ctx context.Context, features *featuremgmt.FeatureMa
if toggle.Enabled || toggle.Writeable || toggle.Warning != "" || inStartup {
state.Toggles = append(state.Toggles, toggle)
}
if toggle.Writeable {
state.AllowEditing = true
}
}
// Make sure the user can actually write values
if state.AllowEditing {
state.AllowEditing = b.features.IsFeatureEditingAllowed() && b.userCanWrite(ctx, nil)
}
return state
}
func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u *user.SignedInUser) bool {
if u == nil {
u, _ = appcontext.User(ctx)
if u == nil {
return false
}
}
ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementWrite))
return ok && err == nil
}
func (b *FeatureFlagAPIBuilder) handleCurrentStatus(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPatch {
b.handlePatchCurrent(w, r)
return
}
state := getResolvedToggleState(r.Context(), b.features)
err := json.NewEncoder(w).Encode(state)
if err != nil {
w.WriteHeader(500)
}
state := b.getResolvedToggleState(r.Context())
_ = json.NewEncoder(w).Encode(state)
}
// NOTE: authz is already handled by the authorizer
func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !b.features.IsFeatureEditingAllowed() {
_, _ = w.Write([]byte("Feature editing is disabled"))
errhttp.Write(ctx, fmt.Errorf("feature editing is not enabled"), w)
return
}
ctx := r.Context()
request := v0alpha1.ResolvedToggleState{}
err := web.Bind(r, &request)
user, err := appcontext.User(ctx)
if err != nil {
_, _ = w.Write([]byte("ERROR!!! " + err.Error()))
errhttp.Write(ctx, err, w)
return
}
if !b.userCanWrite(ctx, user) {
err = errutil.Unauthorized("featuretoggle.canNotWrite",
errutil.WithPublicMessage("missing write permission"))
errhttp.Write(ctx, err, w)
return
}
request := v0alpha1.ResolvedToggleState{}
err = web.Bind(r, &request)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
if len(request.Toggles) > 0 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("can only patch the enabled"))
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can only path the enabled section"))
errhttp.Write(ctx, err, w)
return
}
changes := map[string]bool{}
changes := map[string]string{} // TODO would be nice to have this be a bool on the HG side
for k, v := range request.Enabled {
current := b.features.IsEnabled(ctx, k)
if current != v {
if !b.features.IsEditableFromAdminPage(k) {
err = errutil.BadRequest("featuretoggle.badRequest",
errutil.WithPublicMessage("can not edit toggle: "+k))
errhttp.Write(ctx, err, w)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("can not edit toggle: " + k))
return
}
changes[k] = v
changes[k] = strconv.FormatBool(v)
}
}
@@ -108,5 +152,55 @@ func (b *FeatureFlagAPIBuilder) handlePatchCurrent(w http.ResponseWriter, r *htt
return
}
_, _ = w.Write([]byte("TODO... actually UPDATE/call webhook: "))
payload := featuremgmt.FeatureToggleWebhookPayload{
FeatureToggles: changes,
User: user.Email,
}
err = sendWebhookUpdate(b.features.Settings, payload)
if err != nil {
errhttp.Write(ctx, err, w)
return
}
b.features.SetRestartRequired()
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("feature toggles updated successfully"))
}
func sendWebhookUpdate(cfg setting.FeatureMgmtSettings, payload featuremgmt.FeatureToggleWebhookPayload) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, cfg.UpdateWebhook, bytes.NewBuffer(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.UpdateWebhookToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn("Failed to close response body", "err", err)
}
}()
if resp.StatusCode >= http.StatusBadRequest {
if body, err := io.ReadAll(resp.Body); err != nil {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %s", resp.StatusCode, string(body))
} else {
return fmt.Errorf("SendWebhookUpdate failed with status=%d, error: %w", resp.StatusCode, err)
}
}
return nil
}

View File

@@ -14,6 +14,7 @@ import (
"k8s.io/kube-openapi/pkg/validation/spec"
"github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
@@ -24,20 +25,19 @@ var gv = v0alpha1.SchemeGroupVersion
// This is used just so wire has something unique to return
type FeatureFlagAPIBuilder struct {
features *featuremgmt.FeatureManager
features *featuremgmt.FeatureManager
accessControl accesscontrol.AccessControl
}
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager) *FeatureFlagAPIBuilder {
return &FeatureFlagAPIBuilder{features}
func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl) *FeatureFlagAPIBuilder {
return &FeatureFlagAPIBuilder{features, accessControl}
}
func RegisterAPIService(features *featuremgmt.FeatureManager,
accessControl accesscontrol.AccessControl,
apiregistration builder.APIRegistrar,
) *FeatureFlagAPIBuilder {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil // skip registration unless opting into experimental apis
}
builder := NewFeatureFlagAPIBuilder(features)
builder := NewFeatureFlagAPIBuilder(features, accessControl)
apiregistration.RegisterAPI(builder)
return builder
}
@@ -161,7 +161,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
"enable-auto-migrate": {
ExampleProps: spec3.ExampleProps{
Summary: "enable auto-migrate panels",
Description: "example descr",
Description: "enable description",
Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: true,
@@ -172,7 +172,7 @@ func (b *FeatureFlagAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
"disable-auto-migrate": {
ExampleProps: spec3.ExampleProps{
Summary: "disable auto-migrate panels",
Description: "disable descr",
Description: "disable description",
Value: &v0alpha1.ResolvedToggleState{
Enabled: map[string]bool{
featuremgmt.FlagAutoMigrateOldPanels: false,