From 7e1b45ba31570e24644c9570feb05ae036e38d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Calisto?= Date: Mon, 25 Sep 2023 19:11:24 +0100 Subject: [PATCH] Feature Toggles API: Trigger webhook call when updating (#75254) * Feature Toggles API: Trigger webhook call when updating * update status code error check * lint - handle Close() error * Rename update webhook config * fix tests --- conf/defaults.ini | 5 +- conf/sample.ini | 4 +- .../setup-grafana/configure-grafana/_index.md | 2 +- pkg/api/featuremgmt.go | 68 +++++++++++++++++-- pkg/api/featuremgmt_test.go | 65 ++++++++++++------ pkg/setting/setting_featuremgmt.go | 12 ++-- 6 files changed, 121 insertions(+), 35 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index bacb8195eb8..c5f35c46864 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1676,7 +1676,10 @@ show_ui = true allow_editing = false # Allow customization of URL for the controller that manages feature toggles -update_controller_url = +update_webhook = + +# Allow configuring an auth token for feature management update requests +update_webhook_token = # Hides specific feature toggles from the feature management page hidden_toggles = diff --git a/conf/sample.ini b/conf/sample.ini index e70d3a978a0..ac3a9568e2e 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1545,7 +1545,9 @@ # Allow editing of feature toggles in the feature management page ;allow_editing = false # Allow customization of URL for the controller that manages feature toggles -;update_controller_url = +;update_webhook = +# Allow configuring an auth token for feature management update requests +;update_webhook_token = # Hide specific feature toggles from the feature management page ;hidden_toggles = # Disable updating specific feature toggles in the feature management page diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 4b75b9891e2..2836cc2f728 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2325,7 +2325,7 @@ Please see [Configure feature toggles]({{< relref "./feature-toggles" >}}) for m Lets you switch the feature toggle state in the feature management page. The default is `false`. -### update_controller_url +### update_webhook Set the URL of the controller that manages the feature toggle updates. If not set, feature toggles in the feature management page will be read-only. diff --git a/pkg/api/featuremgmt.go b/pkg/api/featuremgmt.go index e7dbb901036..a3fd7182666 100644 --- a/pkg/api/featuremgmt.go +++ b/pkg/api/featuremgmt.go @@ -1,10 +1,15 @@ package api import ( + "bytes" + "encoding/json" "fmt" + "io" "net/http" + "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" @@ -42,8 +47,8 @@ func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response return response.Error(http.StatusForbidden, "feature toggles are read-only", fmt.Errorf("feature toggles are configured to be read-only")) } - if featureMgmtCfg.UpdateControllerUrl == "" { - return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_controller_url is not set")) + 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{} @@ -51,21 +56,29 @@ func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response 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 f, ok := hs.Features.LookupFlag(t.Name); ok && isFeatureWriteable(f, hs.Cfg.FeatureManagement.ReadOnlyToggles) { hs.log.Info("UpdateFeatureToggle: updating toggle", "toggle_name", t.Name, "enabled", t.Enabled, "username", ctx.SignedInUser.Login) - // TODO build payload + 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)) } } - // TODO: post to featureMgmtCfg.UpdateControllerUrl and return response status - hs.log.Warn("UpdateFeatureToggle: function is unimplemented") + 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") + } - return response.Error(http.StatusNotImplemented, "UpdateFeatureToggle is unimplemented", fmt.Errorf("UpdateFeatureToggle is unimplemented")) + return response.Respond(http.StatusOK, "feature toggles updated successfully") } // isFeatureHidden returns whether a toggle should be hidden from the admin page. @@ -91,5 +104,46 @@ func isFeatureWriteable(flag featuremgmt.FeatureFlag, readOnlyCfg map[string]str // isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI func isFeatureEditingAllowed(cfg setting.Cfg) bool { - return cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateControllerUrl != "" + return cfg.FeatureManagement.AllowEditing && cfg.FeatureManagement.UpdateWebhook != "" +} + +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 } diff --git a/pkg/api/featuremgmt_test.go b/pkg/api/featuremgmt_test.go index fa666645bb1..ae3f52fef45 100644 --- a/pkg/api/featuremgmt_test.go +++ b/pkg/api/featuremgmt_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "testing" "github.com/grafana/grafana/pkg/infra/log" @@ -83,10 +84,10 @@ func TestGetFeatureToggles(t *testing.T) { }, } settings := setting.FeatureMgmtSettings{ - HiddenToggles: map[string]struct{}{"toggle1": {}}, - ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, - AllowEditing: true, - UpdateControllerUrl: "bogus", + HiddenToggles: map[string]struct{}{"toggle1": {}}, + ReadOnlyToggles: map[string]struct{}{"toggle2": {}}, + AllowEditing: true, + UpdateWebhook: "bogus", } result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) @@ -132,8 +133,8 @@ func TestGetFeatureToggles(t *testing.T) { t.Run("only public preview and GA are writeable by default", func(t *testing.T) { settings := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateControllerUrl: "bogus", + AllowEditing: true, + UpdateWebhook: "bogus", } result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) assert.Len(t, result, 3) @@ -151,8 +152,8 @@ func TestGetFeatureToggles(t *testing.T) { t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) { settings := setting.FeatureMgmtSettings{ - AllowEditing: false, - UpdateControllerUrl: "", + AllowEditing: false, + UpdateWebhook: "", } result := runGetScenario(t, features, settings, readPermissions, http.StatusOK) assert.Len(t, result, 3) @@ -216,8 +217,8 @@ func TestSetFeatureToggles(t *testing.T) { } s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateControllerUrl: "random", + AllowEditing: true, + UpdateWebhook: "random", } res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest) defer func() { require.NoError(t, res.Body.Close()) }() @@ -243,8 +244,8 @@ func TestSetFeatureToggles(t *testing.T) { } s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateControllerUrl: "random", + AllowEditing: true, + UpdateWebhook: "random", ReadOnlyToggles: map[string]struct{}{ "toggle3": {}, }, @@ -290,7 +291,7 @@ func TestSetFeatureToggles(t *testing.T) { }) }) - t.Run("succeeds with all conditions met", func(t *testing.T) { + t.Run("when all conditions met", func(t *testing.T) { features := []*featuremgmt.FeatureFlag{ { Name: featuremgmt.FlagFeatureToggleAdminPage, @@ -316,8 +317,9 @@ func TestSetFeatureToggles(t *testing.T) { } s := setting.FeatureMgmtSettings{ - AllowEditing: true, - UpdateControllerUrl: "random", + AllowEditing: true, + UpdateWebhook: "random", + UpdateWebhookToken: "token", ReadOnlyToggles: map[string]struct{}{ "toggle3": {}, }, @@ -332,11 +334,34 @@ func TestSetFeatureToggles(t *testing.T) { Enabled: false, }, } - // TODO: check for success status after the handler is fully implemented - res := runSetScenario(t, features, updates, s, writePermissions, http.StatusNotImplemented) - defer func() { require.NoError(t, res.Body.Close()) }() - p := readBody(t, res.Body) - assert.Equal(t, "UpdateFeatureToggle is unimplemented", p["message"]) + 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, 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, updates, s, writePermissions, http.StatusOK) + defer func() { require.NoError(t, res.Body.Close()) }() + assert.Equal(t, http.StatusOK, res.StatusCode) + }) }) } diff --git a/pkg/setting/setting_featuremgmt.go b/pkg/setting/setting_featuremgmt.go index 5981c982dc5..1887e9e0856 100644 --- a/pkg/setting/setting_featuremgmt.go +++ b/pkg/setting/setting_featuremgmt.go @@ -5,10 +5,11 @@ import ( ) type FeatureMgmtSettings struct { - HiddenToggles map[string]struct{} - ReadOnlyToggles map[string]struct{} - AllowEditing bool - UpdateControllerUrl string + HiddenToggles map[string]struct{} + ReadOnlyToggles map[string]struct{} + AllowEditing bool + UpdateWebhook string + UpdateWebhookToken string } func (cfg *Cfg) readFeatureManagementConfig() { @@ -32,5 +33,6 @@ func (cfg *Cfg) readFeatureManagementConfig() { cfg.FeatureManagement.HiddenToggles = hiddenToggles cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles cfg.FeatureManagement.AllowEditing = cfg.SectionWithEnvOverrides("feature_management").Key("allow_editing").MustBool(false) - cfg.FeatureManagement.UpdateControllerUrl = cfg.SectionWithEnvOverrides("feature_management").Key("update_controller_url").MustString("") + cfg.FeatureManagement.UpdateWebhook = cfg.SectionWithEnvOverrides("feature_management").Key("update_webhook").MustString("") + cfg.FeatureManagement.UpdateWebhookToken = cfg.SectionWithEnvOverrides("feature_management").Key("update_webhook_token").MustString("") }