mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 18:24:20 +08:00
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
This commit is contained in:
@ -1676,7 +1676,10 @@ show_ui = true
|
|||||||
allow_editing = false
|
allow_editing = false
|
||||||
|
|
||||||
# Allow customization of URL for the controller that manages feature toggles
|
# 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
|
# Hides specific feature toggles from the feature management page
|
||||||
hidden_toggles =
|
hidden_toggles =
|
||||||
|
@ -1545,7 +1545,9 @@
|
|||||||
# Allow editing of feature toggles in the feature management page
|
# Allow editing of feature toggles in the feature management page
|
||||||
;allow_editing = false
|
;allow_editing = false
|
||||||
# Allow customization of URL for the controller that manages feature toggles
|
# 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
|
# Hide specific feature toggles from the feature management page
|
||||||
;hidden_toggles =
|
;hidden_toggles =
|
||||||
# Disable updating specific feature toggles in the feature management page
|
# Disable updating specific feature toggles in the feature management page
|
||||||
|
@ -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`.
|
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.
|
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.
|
||||||
|
|
||||||
|
@ -1,10 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"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"))
|
return response.Error(http.StatusForbidden, "feature toggles are read-only", fmt.Errorf("feature toggles are configured to be read-only"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if featureMgmtCfg.UpdateControllerUrl == "" {
|
if featureMgmtCfg.UpdateWebhook == "" {
|
||||||
return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_controller_url is not set"))
|
return response.Error(http.StatusInternalServerError, "feature toggles service is misconfigured", fmt.Errorf("[feature_management]update_webhook is not set"))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := featuremgmt.UpdateFeatureTogglesCommand{}
|
cmd := featuremgmt.UpdateFeatureTogglesCommand{}
|
||||||
@ -51,21 +56,29 @@ func (hs *HTTPServer) UpdateFeatureToggle(ctx *contextmodel.ReqContext) response
|
|||||||
return response.Error(http.StatusBadRequest, "bad request data", err)
|
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 {
|
for _, t := range cmd.FeatureToggles {
|
||||||
// make sure flag exists, and only continue if flag is writeable
|
// 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) {
|
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)
|
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 {
|
} else {
|
||||||
hs.log.Warn("UpdateFeatureToggle: invalid toggle passed in", "toggle_name", t.Name)
|
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))
|
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
|
err := sendWebhookUpdate(featureMgmtCfg, payload, hs.log)
|
||||||
hs.log.Warn("UpdateFeatureToggle: function is unimplemented")
|
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.
|
// 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
|
// isFeatureEditingAllowed checks if the backend is properly configured to allow feature toggle changes from the UI
|
||||||
func isFeatureEditingAllowed(cfg setting.Cfg) bool {
|
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
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
@ -83,10 +84,10 @@ func TestGetFeatureToggles(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
settings := setting.FeatureMgmtSettings{
|
settings := setting.FeatureMgmtSettings{
|
||||||
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
HiddenToggles: map[string]struct{}{"toggle1": {}},
|
||||||
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
|
ReadOnlyToggles: map[string]struct{}{"toggle2": {}},
|
||||||
AllowEditing: true,
|
AllowEditing: true,
|
||||||
UpdateControllerUrl: "bogus",
|
UpdateWebhook: "bogus",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
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) {
|
t.Run("only public preview and GA are writeable by default", func(t *testing.T) {
|
||||||
settings := setting.FeatureMgmtSettings{
|
settings := setting.FeatureMgmtSettings{
|
||||||
AllowEditing: true,
|
AllowEditing: true,
|
||||||
UpdateControllerUrl: "bogus",
|
UpdateWebhook: "bogus",
|
||||||
}
|
}
|
||||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
||||||
assert.Len(t, result, 3)
|
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) {
|
t.Run("all toggles are read-only when server is misconfigured", func(t *testing.T) {
|
||||||
settings := setting.FeatureMgmtSettings{
|
settings := setting.FeatureMgmtSettings{
|
||||||
AllowEditing: false,
|
AllowEditing: false,
|
||||||
UpdateControllerUrl: "",
|
UpdateWebhook: "",
|
||||||
}
|
}
|
||||||
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
result := runGetScenario(t, features, settings, readPermissions, http.StatusOK)
|
||||||
assert.Len(t, result, 3)
|
assert.Len(t, result, 3)
|
||||||
@ -216,8 +217,8 @@ func TestSetFeatureToggles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := setting.FeatureMgmtSettings{
|
s := setting.FeatureMgmtSettings{
|
||||||
AllowEditing: true,
|
AllowEditing: true,
|
||||||
UpdateControllerUrl: "random",
|
UpdateWebhook: "random",
|
||||||
}
|
}
|
||||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusBadRequest)
|
||||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||||
@ -243,8 +244,8 @@ func TestSetFeatureToggles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := setting.FeatureMgmtSettings{
|
s := setting.FeatureMgmtSettings{
|
||||||
AllowEditing: true,
|
AllowEditing: true,
|
||||||
UpdateControllerUrl: "random",
|
UpdateWebhook: "random",
|
||||||
ReadOnlyToggles: map[string]struct{}{
|
ReadOnlyToggles: map[string]struct{}{
|
||||||
"toggle3": {},
|
"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{
|
features := []*featuremgmt.FeatureFlag{
|
||||||
{
|
{
|
||||||
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
Name: featuremgmt.FlagFeatureToggleAdminPage,
|
||||||
@ -316,8 +317,9 @@ func TestSetFeatureToggles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s := setting.FeatureMgmtSettings{
|
s := setting.FeatureMgmtSettings{
|
||||||
AllowEditing: true,
|
AllowEditing: true,
|
||||||
UpdateControllerUrl: "random",
|
UpdateWebhook: "random",
|
||||||
|
UpdateWebhookToken: "token",
|
||||||
ReadOnlyToggles: map[string]struct{}{
|
ReadOnlyToggles: map[string]struct{}{
|
||||||
"toggle3": {},
|
"toggle3": {},
|
||||||
},
|
},
|
||||||
@ -332,11 +334,34 @@ func TestSetFeatureToggles(t *testing.T) {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// TODO: check for success status after the handler is fully implemented
|
t.Run("fail when webhook request is not successful", func(t *testing.T) {
|
||||||
res := runSetScenario(t, features, updates, s, writePermissions, http.StatusNotImplemented)
|
webhookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
p := readBody(t, res.Body)
|
}))
|
||||||
assert.Equal(t, "UpdateFeatureToggle is unimplemented", p["message"])
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,10 +5,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FeatureMgmtSettings struct {
|
type FeatureMgmtSettings struct {
|
||||||
HiddenToggles map[string]struct{}
|
HiddenToggles map[string]struct{}
|
||||||
ReadOnlyToggles map[string]struct{}
|
ReadOnlyToggles map[string]struct{}
|
||||||
AllowEditing bool
|
AllowEditing bool
|
||||||
UpdateControllerUrl string
|
UpdateWebhook string
|
||||||
|
UpdateWebhookToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *Cfg) readFeatureManagementConfig() {
|
func (cfg *Cfg) readFeatureManagementConfig() {
|
||||||
@ -32,5 +33,6 @@ func (cfg *Cfg) readFeatureManagementConfig() {
|
|||||||
cfg.FeatureManagement.HiddenToggles = hiddenToggles
|
cfg.FeatureManagement.HiddenToggles = hiddenToggles
|
||||||
cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles
|
cfg.FeatureManagement.ReadOnlyToggles = readOnlyToggles
|
||||||
cfg.FeatureManagement.AllowEditing = cfg.SectionWithEnvOverrides("feature_management").Key("allow_editing").MustBool(false)
|
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("")
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user