From 054e12b1aca94dea145f15475f80076c279ea20b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 25 Sep 2025 17:39:25 +0300 Subject: [PATCH] FeatureToggles: Remove deprecated experimental apiserver (#111617) --- pkg/registry/apis/apis.go | 2 - pkg/registry/apis/featuretoggle/README.md | 5 - pkg/registry/apis/featuretoggle/current.go | 237 --------- .../apis/featuretoggle/current_test.go | 459 ------------------ pkg/registry/apis/featuretoggle/features.go | 95 ---- pkg/registry/apis/featuretoggle/register.go | 148 ------ pkg/registry/apis/featuretoggle/toggles.go | 91 ---- pkg/registry/apis/wireset.go | 2 - pkg/server/wire_gen.go | 7 +- 9 files changed, 2 insertions(+), 1044 deletions(-) delete mode 100644 pkg/registry/apis/featuretoggle/README.md delete mode 100644 pkg/registry/apis/featuretoggle/current.go delete mode 100644 pkg/registry/apis/featuretoggle/current_test.go delete mode 100644 pkg/registry/apis/featuretoggle/features.go delete mode 100644 pkg/registry/apis/featuretoggle/register.go delete mode 100644 pkg/registry/apis/featuretoggle/toggles.go diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index 315afb0e676..98a09583c85 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -4,7 +4,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/ofrep" @@ -22,7 +21,6 @@ type Service struct{} func ProvideRegistryServiceSink( _ *dashboardinternal.DashboardsAPIBuilder, _ *dashboardsnapshot.SnapshotsAPIBuilder, - _ *featuretoggle.FeatureFlagAPIBuilder, _ *datasource.DataSourceAPIBuilder, _ *folders.FolderAPIBuilder, _ *iam.IdentityAccessManagementAPIBuilder, diff --git a/pkg/registry/apis/featuretoggle/README.md b/pkg/registry/apis/featuretoggle/README.md deleted file mode 100644 index 7267e2e2819..00000000000 --- a/pkg/registry/apis/featuretoggle/README.md +++ /dev/null @@ -1,5 +0,0 @@ -This package supports the [Feature toggle admin page](https://grafana.com/docs/grafana/latest/administration/feature-toggles/) feature. - -In order to update feature toggles through the app, the PATCH handler calls a webhook that should update Grafana's configuration and restarts the instance. - -For local development, set the app mode to `development` by adding `app_mode = development` to the top level of your Grafana .ini file. \ No newline at end of file diff --git a/pkg/registry/apis/featuretoggle/current.go b/pkg/registry/apis/featuretoggle/current.go deleted file mode 100644 index f7602584a85..00000000000 --- a/pkg/registry/apis/featuretoggle/current.go +++ /dev/null @@ -1,237 +0,0 @@ -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/apimachinery/apis/common/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/errutil" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/cmd/grafana-cli/logger" - ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util/errhttp" - "github.com/grafana/grafana/pkg/web" -) - -func (b *FeatureFlagAPIBuilder) getResolvedToggleState(ctx context.Context) v0alpha1.ResolvedToggleState { - state := v0alpha1.ResolvedToggleState{ - TypeMeta: v1.TypeMeta{ - APIVersion: v0alpha1.APIVERSION, - Kind: "ResolvedToggleState", - }, - Enabled: b.features.GetEnabled(ctx), - RestartRequired: b.features.IsRestartRequired(), - } - - // Reference to the object that defined the values - startupRef := &common.ObjectReference{ - Namespace: "system", - Name: "startup", - } - - startup := b.features.GetStartupFlags() - warnings := b.features.GetWarning() - for _, f := range b.features.GetFlags() { - name := f.Name - if b.features.IsHiddenFromAdminPage(name, false) { - continue - } - - toggle := v0alpha1.ToggleStatus{ - Name: name, - Description: f.Description, // simplify the UI changes - Stage: f.Stage.String(), - Enabled: state.Enabled[name], - Writeable: b.features.IsEditableFromAdminPage(name), - Source: startupRef, - Warning: warnings[name], - } - if f.Expression == "true" && toggle.Enabled { - toggle.Source = nil - } - _, inStartup := startup[name] - 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) userCanRead(ctx context.Context, u identity.Requester) bool { - if u == nil { - u, _ = identity.GetRequester(ctx) - if u == nil { - return false - } - } - ok, err := b.accessControl.Evaluate(ctx, u, ac.EvalPermission(ac.ActionFeatureManagementRead)) - return ok && err == nil -} - -func (b *FeatureFlagAPIBuilder) userCanWrite(ctx context.Context, u identity.Requester) bool { - if u == nil { - u, _ = identity.GetRequester(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 - } - - // Check if the user can access toggle info - ctx := r.Context() - user, err := identity.GetRequester(ctx) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if !b.userCanRead(ctx, user) { - err = errutil.Unauthorized("featuretoggle.canNotRead", - errutil.WithPublicMessage("missing read permission")).Errorf("user %s does not have read permissions", user.GetLogin()) - errhttp.Write(ctx, err, w) - return - } - - // Write the state to the response body - state := b.getResolvedToggleState(r.Context()) - w.WriteHeader(http.StatusOK) - _ = 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() { - err := errutil.Forbidden("featuretoggle.disabled", - errutil.WithPublicMessage("feature toggles are read-only")).Errorf("feature toggles are not writeable due to missing configuration") - errhttp.Write(ctx, err, w) - return - } - - user, err := identity.GetRequester(ctx) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if !b.userCanWrite(ctx, user) { - err = errutil.Unauthorized("featuretoggle.canNotWrite", - errutil.WithPublicMessage("missing write permission")).Errorf("user %s does not have write permissions", user.GetLogin()) - 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 { - err = errutil.BadRequest("featuretoggle.badRequest", - errutil.WithPublicMessage("can only patch the enabled section")).Errorf("request payload included properties in the read-only Toggles section") - errhttp.Write(ctx, err, w) - return - } - - 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("invalid toggle passed in")).Errorf("can not edit toggle %s", k) - errhttp.Write(ctx, err, w) - w.WriteHeader(http.StatusBadRequest) - return - } - changes[k] = strconv.FormatBool(v) - } - } - - if len(changes) == 0 { - w.WriteHeader(http.StatusNotModified) - return - } - - payload := featuremgmt.FeatureToggleWebhookPayload{ - FeatureToggles: changes, - User: user.GetEmail(), - } - - err = sendWebhookUpdate(b.features.Settings, payload) - if err != nil && b.cfg.Env != setting.Dev { - err = errutil.Internal("featuretoggle.webhookFailure", errutil.WithPublicMessage("an error occurred while updating feeature toggles")).Errorf("webhook error: %w", err) - 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 -} diff --git a/pkg/registry/apis/featuretoggle/current_test.go b/pkg/registry/apis/featuretoggle/current_test.go deleted file mode 100644 index e37dff81cbc..00000000000 --- a/pkg/registry/apis/featuretoggle/current_test.go +++ /dev/null @@ -1,459 +0,0 @@ -package featuretoggle - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetFeatureToggles(t *testing.T) { - t.Run("fails without adequate permissions", func(t *testing.T) { - features := featuremgmt.WithFeatureManager(setting.FeatureMgmtSettings{}, []*featuremgmt.FeatureFlag{{ - // Add this here to ensure the feature works as expected during tests - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}) - - b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) - - callGetWith(t, b, http.StatusUnauthorized) - }) - - t.Run("should be able to get feature toggles", func(t *testing.T) { - features := []*featuremgmt.FeatureFlag{ - { - Name: "toggle1", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, { - Name: "toggle2", - Stage: featuremgmt.FeatureStageGeneralAvailability, - }, - } - disabled := []string{"toggle2"} - - b := newTestAPIBuilder(t, features, disabled, setting.FeatureMgmtSettings{}) - result := callGetWith(t, b, http.StatusOK) - assert.Len(t, result.Toggles, 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": {}}, - } - - b := newTestAPIBuilder(t, features, []string{}, settings) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 1) - assert.Equal(t, "toggle2", result.Toggles[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", - } - - b := newTestAPIBuilder(t, features, disabled, settings) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 1) - assert.Equal(t, "toggle2", result.Toggles[0].Name) - assert.False(t, result.Toggles[0].Writeable) - }) - - 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) { - b := newTestAPIBuilder(t, features, []string{}, setting.FeatureMgmtSettings{}) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 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", - } - - b := newTestAPIBuilder(t, features, []string{}, settings) - result := callGetWith(t, b, http.StatusOK) - - t4, ok := findResult(t, result, "toggle4") - assert.True(t, ok) - assert.True(t, t4.Writeable) - t5, ok := findResult(t, result, "toggle5") - assert.True(t, ok) - assert.True(t, t5.Writeable) - t6, ok := findResult(t, result, "toggle6") - assert.True(t, ok) - assert.True(t, t6.Writeable) - }) - - t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { - settings := setting.FeatureMgmtSettings{ - AllowEditing: false, - UpdateWebhook: "", - } - b := newTestAPIBuilder(t, features, []string{}, settings) - result := callGetWith(t, b, http.StatusOK) - - assert.Len(t, result.Toggles, 4) - - t4, ok := findResult(t, result, "toggle4") - assert.True(t, ok) - assert.False(t, t4.Writeable) - t5, ok := findResult(t, result, "toggle5") - assert.True(t, ok) - assert.False(t, t5.Writeable) - t6, ok := findResult(t, result, "toggle6") - assert.True(t, ok) - assert.False(t, t6.Writeable) - }) - }) -} - -func TestSetFeatureToggles(t *testing.T) { - t.Run("fails when the user doesn't have write permissions", func(t *testing.T) { - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - } - features := featuremgmt.WithFeatureManager(s, []*featuremgmt.FeatureFlag{{ - // Add this here to ensure the feature works as expected during tests - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}) - - b := NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: false}, &setting.Cfg{}) - msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusUnauthorized) - assert.Equal(t, "missing write permission", msg) - }) - - t.Run("fails when update toggle url is not set", func(t *testing.T) { - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - } - b := newTestAPIBuilder(t, nil, []string{}, s) - msg := callPatchWith(t, b, v0alpha1.ResolvedToggleState{}, http.StatusForbidden) - assert.Equal(t, "feature toggles are read-only", msg) - }) - - 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"} - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle3": true, - }, - } - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - } - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusBadRequest) - assert.Equal(t, "invalid toggle passed in", msg) - }) - - 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) { - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - featuremgmt.FlagFeatureToggleAdminPage: true, - }, - } - b := newTestAPIBuilder(t, features, disabled, s) - callPatchWith(t, b, update, http.StatusNotModified) - }) - - t.Run("because it is not GA or Deprecated", func(t *testing.T) { - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle2": true, - }, - } - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusBadRequest) - assert.Equal(t, "invalid toggle passed in", msg) - }) - - t.Run("because it is configured to be read-only", func(t *testing.T) { - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle2": true, - }, - } - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusBadRequest) - assert.Equal(t, "invalid toggle passed in", msg) - }) - }) - - 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"} - - s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateWebhook: "random", - UpdateWebhookToken: "token", - ReadOnlyToggles: map[string]struct{}{ - "toggle3": {}, - }, - } - - update := v0alpha1.ResolvedToggleState{ - Enabled: map[string]bool{ - "toggle4": true, - "toggle5": 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 - - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusInternalServerError) - assert.Equal(t, "an error occurred while updating feeature toggles", msg) - }) - - t.Run("succeed when webhook request is not successful but app is in dev mode", 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 - - b := newTestAPIBuilder(t, features, disabled, s) - b.cfg.Env = setting.Dev - callPatchWith(t, b, update, http.StatusOK) - }) - - t.Run("succeed when webhook request is successful", 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 featuremgmt.FeatureToggleWebhookPayload - 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 - - b := newTestAPIBuilder(t, features, disabled, s) - msg := callPatchWith(t, b, update, http.StatusOK) - assert.Equal(t, "feature toggles updated successfully", msg) - }) - }) -} - -func findResult(t *testing.T, result v0alpha1.ResolvedToggleState, name string) (v0alpha1.ToggleStatus, bool) { - t.Helper() - - for _, t := range result.Toggles { - if t.Name == name { - return t, true - } - } - return v0alpha1.ToggleStatus{}, false -} - -func callGetWith(t *testing.T, b *FeatureFlagAPIBuilder, expectedCode int) v0alpha1.ResolvedToggleState { - w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) - req := &http.Request{ - Method: "GET", - Header: http.Header{}, - } - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{})) - b.handleCurrentStatus(w, req) - - rts := v0alpha1.ResolvedToggleState{} - require.NoError(t, json.Unmarshal(w.Body(), &rts)) - require.Equal(t, expectedCode, w.Status()) - - // Tests don't expect the feature toggle admin page feature to be present, so remove them from the resolved toggle state - for i, t := range rts.Toggles { - if t.Name == "featureToggleAdminPage" { - rts.Toggles = append(rts.Toggles[0:i], rts.Toggles[i+1:]...) - } - } - - return rts -} - -func callPatchWith(t *testing.T, b *FeatureFlagAPIBuilder, update v0alpha1.ResolvedToggleState, expectedCode int) string { - w := response.CreateNormalResponse(http.Header{}, []byte{}, 0) - - body, err := json.Marshal(update) - require.NoError(t, err) - - req := &http.Request{ - Method: "PATCH", - Body: io.NopCloser(bytes.NewReader(body)), - Header: http.Header{}, - } - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{})) - b.handleCurrentStatus(w, req) - - require.NotNil(t, w.Body()) - require.Equal(t, expectedCode, w.Status()) - - // Extract the public facing message if this is an error - if w.Status() > 399 { - res := map[string]any{} - require.NoError(t, json.Unmarshal(w.Body(), &res)) - - return res["message"].(string) - } - - return string(w.Body()) -} - -func newTestAPIBuilder( - t *testing.T, - serverFeatures []*featuremgmt.FeatureFlag, - disabled []string, // the flags that are disabled - settings setting.FeatureMgmtSettings, -) *FeatureFlagAPIBuilder { - t.Helper() - features := featuremgmt.WithFeatureManager(settings, append([]*featuremgmt.FeatureFlag{{ - // Add this here to ensure the feature works as expected during tests - Name: featuremgmt.FlagFeatureToggleAdminPage, - Stage: featuremgmt.FeatureStageGeneralAvailability, - }}, serverFeatures...), disabled...) - - return NewFeatureFlagAPIBuilder(features, actest.FakeAccessControl{ExpectedEvaluate: true}, &setting.Cfg{}) -} diff --git a/pkg/registry/apis/featuretoggle/features.go b/pkg/registry/apis/featuretoggle/features.go deleted file mode 100644 index fa7cf10a7fb..00000000000 --- a/pkg/registry/apis/featuretoggle/features.go +++ /dev/null @@ -1,95 +0,0 @@ -package featuretoggle - -import ( - "context" - "fmt" - "sync" - - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" -) - -var ( - _ rest.Storage = (*featuresStorage)(nil) - _ rest.Scoper = (*featuresStorage)(nil) - _ rest.SingularNameProvider = (*featuresStorage)(nil) - _ rest.Lister = (*featuresStorage)(nil) - _ rest.Getter = (*featuresStorage)(nil) -) - -type featuresStorage struct { - resource *utils.ResourceInfo - tableConverter rest.TableConvertor - features *v0alpha1.FeatureList - featuresOnce sync.Once -} - -// NOTE! this does not depend on config or any system state! -// In the future, the existence of features (and their properties) can be defined dynamically -func NewFeaturesStorage() *featuresStorage { - resourceInfo := v0alpha1.FeatureResourceInfo - return &featuresStorage{ - resource: &resourceInfo, - tableConverter: resourceInfo.TableConverter(), - } -} - -func (s *featuresStorage) New() runtime.Object { - return s.resource.NewFunc() -} - -func (s *featuresStorage) Destroy() {} - -func (s *featuresStorage) NamespaceScoped() bool { - return false -} - -func (s *featuresStorage) GetSingularName() string { - return s.resource.GetSingularName() -} - -func (s *featuresStorage) NewList() runtime.Object { - return s.resource.NewListFunc() -} - -func (s *featuresStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return s.tableConverter.ConvertToTable(ctx, object, tableOptions) -} - -func (s *featuresStorage) init() { - s.featuresOnce.Do(func() { - rv := "1" - features, _ := featuremgmt.GetEmbeddedFeatureList() - for _, feature := range features.Items { - if feature.ResourceVersion > rv { - rv = feature.ResourceVersion - } - } - features.ResourceVersion = rv - s.features = &features - }) -} - -func (s *featuresStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - s.init() - if s.features == nil { - return nil, fmt.Errorf("error loading embedded features") - } - return s.features, nil -} - -func (s *featuresStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - s.init() - - for idx, flag := range s.features.Items { - if flag.Name == name { - return &s.features.Items[idx], nil - } - } - return nil, fmt.Errorf("not found") -} diff --git a/pkg/registry/apis/featuretoggle/register.go b/pkg/registry/apis/featuretoggle/register.go deleted file mode 100644 index ef80d7c618e..00000000000 --- a/pkg/registry/apis/featuretoggle/register.go +++ /dev/null @@ -1,148 +0,0 @@ -package featuretoggle - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apiserver/pkg/authorization/authorizer" - "k8s.io/apiserver/pkg/registry/rest" - genericapiserver "k8s.io/apiserver/pkg/server" - common "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - "github.com/prometheus/client_golang/prometheus" - - "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" - "github.com/grafana/grafana/pkg/setting" -) - -var _ builder.APIGroupBuilder = (*FeatureFlagAPIBuilder)(nil) -var _ builder.APIGroupRouteProvider = (*FeatureFlagAPIBuilder)(nil) - -var gv = v0alpha1.SchemeGroupVersion - -// This is used just so wire has something unique to return -type FeatureFlagAPIBuilder struct { - features *featuremgmt.FeatureManager - accessControl accesscontrol.AccessControl - cfg *setting.Cfg -} - -func NewFeatureFlagAPIBuilder(features *featuremgmt.FeatureManager, accessControl accesscontrol.AccessControl, cfg *setting.Cfg) *FeatureFlagAPIBuilder { - return &FeatureFlagAPIBuilder{features, accessControl, cfg} -} - -func RegisterAPIService(features *featuremgmt.FeatureManager, - accessControl accesscontrol.AccessControl, - apiregistration builder.APIRegistrar, - cfg *setting.Cfg, - registerer prometheus.Registerer, -) *FeatureFlagAPIBuilder { - builder := NewFeatureFlagAPIBuilder(features, accessControl, cfg) - apiregistration.RegisterAPI(builder) - return builder -} - -func (b *FeatureFlagAPIBuilder) GetGroupVersion() schema.GroupVersion { - return gv -} - -func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { - scheme.AddKnownTypes(gv, - &v0alpha1.Feature{}, - &v0alpha1.FeatureList{}, - &v0alpha1.FeatureToggles{}, - &v0alpha1.FeatureTogglesList{}, - &v0alpha1.ResolvedToggleState{}, - ) -} - -func (b *FeatureFlagAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - addKnownTypes(scheme, gv) - - // Link this version to the internal representation. - // This is used for server-side-apply (PATCH), and avoids the error: - // "no kind is registered for the type" - addKnownTypes(scheme, schema.GroupVersion{ - Group: gv.Group, - Version: runtime.APIVersionInternal, - }) - - // If multiple versions exist, then register conversions from zz_generated.conversion.go - // if err := playlist.RegisterConversions(scheme); err != nil { - // return err - // } - metav1.AddToGroupVersion(scheme, gv) - return scheme.SetVersionPriority(gv) -} - -func (b *FeatureFlagAPIBuilder) AllowedV0Alpha1Resources() []string { - return nil -} - -func (b *FeatureFlagAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, _ builder.APIGroupOptions) error { - featureStore := NewFeaturesStorage() - toggleStore := NewTogglesStorage(b.features) - - storage := map[string]rest.Storage{} - storage[featureStore.resource.StoragePath()] = featureStore - storage[toggleStore.resource.StoragePath()] = toggleStore - - apiGroupInfo.VersionedResourcesStorageMap[v0alpha1.VERSION] = storage - return nil -} - -func (b *FeatureFlagAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { - return v0alpha1.GetOpenAPIDefinitions -} - -func (b *FeatureFlagAPIBuilder) GetAuthorizer() authorizer.Authorizer { - return nil // default authorizer is fine -} - -// Register additional routes with the server -func (b *FeatureFlagAPIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { - defs := v0alpha1.GetOpenAPIDefinitions(func(path string) spec.Ref { return spec.Ref{} }) - stateSchema := defs["github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1.ResolvedToggleState"].Schema - - tags := []string{"Editor"} - return &builder.APIRoutes{ - Root: []builder.APIRouteHandler{ - { - Path: "current", - Spec: &spec3.PathProps{ - Get: &spec3.Operation{ - OperationProps: spec3.OperationProps{ - Tags: tags, - Summary: "Current configuration with details", - Description: "Show details about the current flags and where they come from", - Responses: &spec3.Responses{ - ResponsesProps: spec3.ResponsesProps{ - StatusCodeResponses: map[int]*spec3.Response{ - 200: { - ResponseProps: spec3.ResponseProps{ - Content: map[string]*spec3.MediaType{ - "application/json": { - MediaTypeProps: spec3.MediaTypeProps{ - Schema: &stateSchema, - }, - }, - }, - Description: "OK", - }, - }, - }, - }, - }, - }, - }, - }, - Handler: b.handleCurrentStatus, - }, - }, - } -} diff --git a/pkg/registry/apis/featuretoggle/toggles.go b/pkg/registry/apis/featuretoggle/toggles.go deleted file mode 100644 index 5d84bcc7843..00000000000 --- a/pkg/registry/apis/featuretoggle/toggles.go +++ /dev/null @@ -1,91 +0,0 @@ -package featuretoggle - -import ( - "context" - "fmt" - - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/apis/featuretoggle/v0alpha1" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "k8s.io/apimachinery/pkg/apis/meta/internalversion" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" -) - -var ( - _ rest.Storage = (*togglesStorage)(nil) - _ rest.Scoper = (*togglesStorage)(nil) - _ rest.SingularNameProvider = (*togglesStorage)(nil) - _ rest.Lister = (*togglesStorage)(nil) - _ rest.Getter = (*togglesStorage)(nil) -) - -type togglesStorage struct { - resource *utils.ResourceInfo - tableConverter rest.TableConvertor - - // The startup toggles - startup *v0alpha1.FeatureToggles -} - -func NewTogglesStorage(features *featuremgmt.FeatureManager) *togglesStorage { - resourceInfo := v0alpha1.TogglesResourceInfo - return &togglesStorage{ - resource: &resourceInfo, - startup: &v0alpha1.FeatureToggles{ - TypeMeta: resourceInfo.TypeMeta(), - ObjectMeta: metav1.ObjectMeta{ - Name: "startup", - Namespace: "system", - CreationTimestamp: metav1.Now(), - }, - Spec: features.GetStartupFlags(), - }, - tableConverter: rest.NewDefaultTableConvertor(resourceInfo.GroupResource()), - } -} - -func (s *togglesStorage) New() runtime.Object { - return s.resource.NewFunc() -} - -func (s *togglesStorage) Destroy() {} - -func (s *togglesStorage) NamespaceScoped() bool { - return true -} - -func (s *togglesStorage) GetSingularName() string { - return s.resource.GetSingularName() -} - -func (s *togglesStorage) NewList() runtime.Object { - return s.resource.NewListFunc() -} - -func (s *togglesStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { - return s.tableConverter.ConvertToTable(ctx, object, tableOptions) -} - -func (s *togglesStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { - flags := &v0alpha1.FeatureTogglesList{ - Items: []v0alpha1.FeatureToggles{*s.startup}, - } - return flags, nil -} - -func (s *togglesStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - info, err := request.NamespaceInfoFrom(ctx, false) // allow system - if err != nil { - return nil, err - } - if info.Value != "" && info.Value != "system" { - return nil, fmt.Errorf("only system namespace is currently supported") - } - if name != "startup" { - return nil, fmt.Errorf("only system/startup is currently supported") - } - return s.startup, nil -} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 0f3e7f8969b..0dd28d4d274 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -6,7 +6,6 @@ import ( dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" @@ -54,7 +53,6 @@ var WireSet = wire.NewSet( // Each must be added here *and* in the ServiceSink above dashboardinternal.RegisterAPIService, dashboardsnapshot.RegisterAPIService, - featuretoggle.RegisterAPIService, datasource.RegisterAPIService, folders.RegisterAPIService, iam.RegisterAPIService, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 40e49ead04c..61b6607a162 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -53,7 +53,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" - "github.com/grafana/grafana/pkg/registry/apis/featuretoggle" "github.com/grafana/grafana/pkg/registry/apis/folders" "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" @@ -811,7 +810,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) - featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer) if err != nil { return nil, err @@ -863,7 +861,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err @@ -1419,7 +1417,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService) snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer) - featureFlagAPIBuilder := featuretoggle.RegisterAPIService(featureManager, accessControl, apiserverService, cfg, registerer) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, pluginstoreService, accessControl, registerer) if err != nil { return nil, err @@ -1471,7 +1468,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err