mirror of
https://github.com/grafana/grafana.git
synced 2026-08-18 17:15:08 -05:00
FeatureFlags: define features outside settings.Cfg (take 3) (#44443)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type FeatureToggles interface {
|
||||
IsEnabled(flag string) bool
|
||||
}
|
||||
|
||||
// FeatureFlagState indicates the quality level
|
||||
type FeatureFlagState int
|
||||
|
||||
const (
|
||||
// FeatureStateUnknown indicates that no state is specified
|
||||
FeatureStateUnknown FeatureFlagState = iota
|
||||
|
||||
// FeatureStateAlpha the feature is in active development and may change at any time
|
||||
FeatureStateAlpha
|
||||
|
||||
// FeatureStateBeta the feature is still in development, but settings will have migrations
|
||||
FeatureStateBeta
|
||||
|
||||
// FeatureStateStable this is a stable feature
|
||||
FeatureStateStable
|
||||
|
||||
// FeatureStateDeprecated the feature will be removed in the future
|
||||
FeatureStateDeprecated
|
||||
)
|
||||
|
||||
func (s FeatureFlagState) String() string {
|
||||
switch s {
|
||||
case FeatureStateAlpha:
|
||||
return "alpha"
|
||||
case FeatureStateBeta:
|
||||
return "beta"
|
||||
case FeatureStateStable:
|
||||
return "stable"
|
||||
case FeatureStateDeprecated:
|
||||
return "deprecated"
|
||||
case FeatureStateUnknown:
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// MarshalJSON marshals the enum as a quoted json string
|
||||
func (s FeatureFlagState) MarshalJSON() ([]byte, error) {
|
||||
buffer := bytes.NewBufferString(`"`)
|
||||
buffer.WriteString(s.String())
|
||||
buffer.WriteString(`"`)
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON unmarshals a quoted json string to the enum value
|
||||
func (s *FeatureFlagState) UnmarshalJSON(b []byte) error {
|
||||
var j string
|
||||
err := json.Unmarshal(b, &j)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch j {
|
||||
case "alpha":
|
||||
*s = FeatureStateAlpha
|
||||
|
||||
case "beta":
|
||||
*s = FeatureStateBeta
|
||||
|
||||
case "stable":
|
||||
*s = FeatureStateStable
|
||||
|
||||
case "deprecated":
|
||||
*s = FeatureStateDeprecated
|
||||
|
||||
default:
|
||||
*s = FeatureStateUnknown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type FeatureFlag struct {
|
||||
Name string `json:"name" yaml:"name"` // Unique name
|
||||
Description string `json:"description"`
|
||||
State FeatureFlagState `json:"state,omitempty"`
|
||||
DocsURL string `json:"docsURL,omitempty"`
|
||||
|
||||
// CEL-GO expression. Using the value "true" will mean this is on by default
|
||||
Expression string `json:"expression,omitempty"`
|
||||
|
||||
// Special behavior flags
|
||||
RequiresDevMode bool `json:"requiresDevMode,omitempty"` // can not be enabled in production
|
||||
RequiresRestart bool `json:"requiresRestart,omitempty"` // The server must be initialized with the value
|
||||
RequiresLicense bool `json:"requiresLicense,omitempty"` // Must be enabled in the license
|
||||
FrontendOnly bool `json:"frontend,omitempty"` // change is only seen in the frontend
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
)
|
||||
|
||||
var (
|
||||
_ FeatureToggles = (*FeatureManager)(nil)
|
||||
)
|
||||
|
||||
type FeatureManager struct {
|
||||
isDevMod bool
|
||||
licensing models.Licensing
|
||||
flags map[string]*FeatureFlag
|
||||
enabled map[string]bool // only the "on" values
|
||||
config string // path to config file
|
||||
vars map[string]interface{}
|
||||
log log.Logger
|
||||
}
|
||||
|
||||
// This will merge the flags with the current configuration
|
||||
func (fm *FeatureManager) registerFlags(flags ...FeatureFlag) {
|
||||
for _, add := range flags {
|
||||
if add.Name == "" {
|
||||
continue // skip it with warning?
|
||||
}
|
||||
flag, ok := fm.flags[add.Name]
|
||||
if !ok {
|
||||
f := add // make a copy
|
||||
fm.flags[add.Name] = &f
|
||||
continue
|
||||
}
|
||||
|
||||
// Selectively update properties
|
||||
if add.Description != "" {
|
||||
flag.Description = add.Description
|
||||
}
|
||||
if add.DocsURL != "" {
|
||||
flag.DocsURL = add.DocsURL
|
||||
}
|
||||
if add.Expression != "" {
|
||||
flag.Expression = add.Expression
|
||||
}
|
||||
|
||||
// The most recently defined state
|
||||
if add.State != FeatureStateUnknown {
|
||||
flag.State = add.State
|
||||
}
|
||||
|
||||
// Only gets more restrictive
|
||||
if add.RequiresDevMode {
|
||||
flag.RequiresDevMode = true
|
||||
}
|
||||
|
||||
if add.RequiresLicense {
|
||||
flag.RequiresLicense = true
|
||||
}
|
||||
|
||||
if add.RequiresRestart {
|
||||
flag.RequiresRestart = true
|
||||
}
|
||||
}
|
||||
|
||||
// This will evaluate all flags
|
||||
fm.update()
|
||||
}
|
||||
|
||||
func (fm *FeatureManager) evaluate(ff *FeatureFlag) bool {
|
||||
if ff.RequiresDevMode && !fm.isDevMod {
|
||||
return false
|
||||
}
|
||||
|
||||
if ff.RequiresLicense && (fm.licensing == nil || !fm.licensing.FeatureEnabled(ff.Name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO: CEL - expression
|
||||
return ff.Expression == "true"
|
||||
}
|
||||
|
||||
// Update
|
||||
func (fm *FeatureManager) update() {
|
||||
enabled := make(map[string]bool)
|
||||
for _, flag := range fm.flags {
|
||||
val := fm.evaluate(flag)
|
||||
|
||||
// Update the registry
|
||||
track := 0.0
|
||||
if val {
|
||||
track = 1
|
||||
enabled[flag.Name] = true
|
||||
}
|
||||
|
||||
// Register value with prometheus metric
|
||||
featureToggleInfo.WithLabelValues(flag.Name).Set(track)
|
||||
}
|
||||
fm.enabled = enabled
|
||||
}
|
||||
|
||||
// Run is called by background services
|
||||
func (fm *FeatureManager) readFile() error {
|
||||
if fm.config == "" {
|
||||
return nil // not configured
|
||||
}
|
||||
|
||||
cfg, err := readConfigFile(fm.config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fm.registerFlags(cfg.Flags...)
|
||||
fm.vars = cfg.Vars
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsEnabled checks if a feature is enabled
|
||||
func (fm *FeatureManager) IsEnabled(flag string) bool {
|
||||
return fm.enabled[flag]
|
||||
}
|
||||
|
||||
// GetEnabled returns a map contaning only the features that are enabled
|
||||
func (fm *FeatureManager) GetEnabled(ctx context.Context) map[string]bool {
|
||||
enabled := make(map[string]bool, len(fm.enabled))
|
||||
for key, val := range fm.enabled {
|
||||
if val {
|
||||
enabled[key] = true
|
||||
}
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
// GetFlags returns all flag definitions
|
||||
func (fm *FeatureManager) GetFlags() []FeatureFlag {
|
||||
v := make([]FeatureFlag, 0, len(fm.flags))
|
||||
for _, value := range fm.flags {
|
||||
v = append(v, *value)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (fm *FeatureManager) HandleGetSettings(c *models.ReqContext) {
|
||||
res := make(map[string]interface{}, 3)
|
||||
res["enabled"] = fm.GetEnabled(c.Req.Context())
|
||||
|
||||
vv := make([]*FeatureFlag, 0, len(fm.flags))
|
||||
for _, v := range fm.flags {
|
||||
vv = append(vv, v)
|
||||
}
|
||||
|
||||
res["info"] = vv
|
||||
|
||||
response.JSON(200, res).WriteTo(c)
|
||||
}
|
||||
|
||||
// WithFeatures is used to define feature toggles for testing.
|
||||
// The arguments are a list of strings that are optionally followed by a boolean value
|
||||
func WithFeatures(spec ...interface{}) *FeatureManager {
|
||||
count := len(spec)
|
||||
enabled := make(map[string]bool, count)
|
||||
|
||||
idx := 0
|
||||
for idx < count {
|
||||
key := fmt.Sprintf("%v", spec[idx])
|
||||
val := true
|
||||
idx++
|
||||
if idx < count && reflect.TypeOf(spec[idx]).Kind() == reflect.Bool {
|
||||
val = spec[idx].(bool)
|
||||
idx++
|
||||
}
|
||||
|
||||
if val {
|
||||
enabled[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
return &FeatureManager{enabled: enabled}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFeatureManager(t *testing.T) {
|
||||
t.Run("check testing stubs", func(t *testing.T) {
|
||||
ft := WithFeatures("a", "b", "c")
|
||||
require.True(t, ft.IsEnabled("a"))
|
||||
require.True(t, ft.IsEnabled("b"))
|
||||
require.True(t, ft.IsEnabled("c"))
|
||||
require.False(t, ft.IsEnabled("d"))
|
||||
|
||||
require.Equal(t, map[string]bool{"a": true, "b": true, "c": true}, ft.GetEnabled(context.Background()))
|
||||
|
||||
// Explicit values
|
||||
ft = WithFeatures("a", true, "b", false)
|
||||
require.True(t, ft.IsEnabled("a"))
|
||||
require.False(t, ft.IsEnabled("b"))
|
||||
require.Equal(t, map[string]bool{"a": true}, ft.GetEnabled(context.Background()))
|
||||
})
|
||||
|
||||
t.Run("check license validation", func(t *testing.T) {
|
||||
ft := FeatureManager{
|
||||
flags: map[string]*FeatureFlag{},
|
||||
}
|
||||
ft.registerFlags(FeatureFlag{
|
||||
Name: "a",
|
||||
RequiresLicense: true,
|
||||
RequiresDevMode: true,
|
||||
Expression: "true",
|
||||
}, FeatureFlag{
|
||||
Name: "b",
|
||||
Expression: "true",
|
||||
})
|
||||
require.False(t, ft.IsEnabled("a"))
|
||||
require.True(t, ft.IsEnabled("b"))
|
||||
require.False(t, ft.IsEnabled("c")) // uknown flag
|
||||
|
||||
// Try changing "requires license"
|
||||
ft.registerFlags(FeatureFlag{
|
||||
Name: "a",
|
||||
RequiresLicense: false, // shuld still require license!
|
||||
}, FeatureFlag{
|
||||
Name: "b",
|
||||
RequiresLicense: true, // expression is still "true"
|
||||
})
|
||||
require.False(t, ft.IsEnabled("a"))
|
||||
require.False(t, ft.IsEnabled("b"))
|
||||
require.False(t, ft.IsEnabled("c"))
|
||||
})
|
||||
|
||||
t.Run("check description and docs configs", func(t *testing.T) {
|
||||
ft := FeatureManager{
|
||||
flags: map[string]*FeatureFlag{},
|
||||
}
|
||||
ft.registerFlags(FeatureFlag{
|
||||
Name: "a",
|
||||
Description: "first",
|
||||
}, FeatureFlag{
|
||||
Name: "a",
|
||||
Description: "second",
|
||||
}, FeatureFlag{
|
||||
Name: "a",
|
||||
DocsURL: "http://something",
|
||||
}, FeatureFlag{
|
||||
Name: "a",
|
||||
})
|
||||
flag := ft.flags["a"]
|
||||
require.Equal(t, "second", flag.Description)
|
||||
require.Equal(t, "http://something", flag.DocsURL)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package featuremgmt
|
||||
|
||||
var (
|
||||
// Register each toggle here
|
||||
standardFeatureFlags = []FeatureFlag{
|
||||
{
|
||||
Name: "trimDefaults",
|
||||
Description: "Use cue schema to remove values that will be applied automatically",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: "envelopeEncryption",
|
||||
Description: "encrypt secrets",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
|
||||
{
|
||||
Name: "httpclientprovider_azure_auth",
|
||||
Description: "use http client for azure auth",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: "service-accounts",
|
||||
Description: "support service accounts",
|
||||
State: FeatureStateBeta,
|
||||
RequiresLicense: true,
|
||||
},
|
||||
|
||||
{
|
||||
Name: "database_metrics",
|
||||
Description: "Add prometheus metrics for database tables",
|
||||
State: FeatureStateStable,
|
||||
},
|
||||
{
|
||||
Name: "dashboardPreviews",
|
||||
Description: "Create and show thumbnails for dashboard search results",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "live-config",
|
||||
Description: "Save grafana live configuration in SQL tables",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "live-pipeline",
|
||||
Description: "enable a generic live processing pipeline",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "live-service-web-worker",
|
||||
Description: "This will use a webworker thread to processes events rather than the main thread",
|
||||
State: FeatureStateAlpha,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "queryOverLive",
|
||||
Description: "Use grafana live websocket to execute backend queries",
|
||||
State: FeatureStateAlpha,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "tempoSearch",
|
||||
Description: "Enable searching in tempo datasources",
|
||||
State: FeatureStateBeta,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "tempoBackendSearch",
|
||||
Description: "Use backend for tempo search",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: "tempoServiceGraph",
|
||||
Description: "show service",
|
||||
State: FeatureStateBeta,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "fullRangeLogsVolume",
|
||||
Description: "Show full range logs volume in explore",
|
||||
State: FeatureStateBeta,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "accesscontrol",
|
||||
Description: "Support robust access control",
|
||||
State: FeatureStateBeta,
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "prometheus_azure_auth",
|
||||
Description: "Use azure authentication for prometheus datasource",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: "newNavigation",
|
||||
Description: "Try the next gen navigation model",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "showFeatureFlagsInUI",
|
||||
Description: "Show feature flags in the settings UI",
|
||||
State: FeatureStateAlpha,
|
||||
RequiresDevMode: true,
|
||||
},
|
||||
{
|
||||
Name: "disable_http_request_histogram",
|
||||
Description: "Do not create histograms for http requests",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "validatedQueries",
|
||||
Description: "only execute the query saved in a panel",
|
||||
State: FeatureStateAlpha,
|
||||
RequiresDevMode: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
// The values are updated each time
|
||||
featureToggleInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "feature_toggles_info",
|
||||
Help: "info metric that exposes what feature toggles are enabled or not",
|
||||
Namespace: "grafana",
|
||||
}, []string{"name"})
|
||||
)
|
||||
|
||||
func ProvideManagerService(cfg *setting.Cfg, licensing models.Licensing) (*FeatureManager, error) {
|
||||
mgmt := &FeatureManager{
|
||||
isDevMod: setting.Env != setting.Prod,
|
||||
licensing: licensing,
|
||||
flags: make(map[string]*FeatureFlag, 30),
|
||||
enabled: make(map[string]bool),
|
||||
log: log.New("featuremgmt"),
|
||||
}
|
||||
|
||||
// Register the standard flags
|
||||
mgmt.registerFlags(standardFeatureFlags...)
|
||||
|
||||
// Load the flags from `custom.ini` files
|
||||
flags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
|
||||
if err != nil {
|
||||
return mgmt, err
|
||||
}
|
||||
for key, val := range flags {
|
||||
flag, ok := mgmt.flags[key]
|
||||
if !ok {
|
||||
flag = &FeatureFlag{
|
||||
Name: key,
|
||||
State: FeatureStateUnknown,
|
||||
}
|
||||
mgmt.flags[key] = flag
|
||||
}
|
||||
flag.Expression = fmt.Sprintf("%t", val) // true | false
|
||||
}
|
||||
|
||||
// Load config settings
|
||||
configfile := filepath.Join(cfg.HomePath, "conf", "features.yaml")
|
||||
if _, err := os.Stat(configfile); err == nil {
|
||||
mgmt.log.Info("[experimental] loading features from config file", "path", configfile)
|
||||
mgmt.config = configfile
|
||||
err = mgmt.readFile()
|
||||
if err != nil {
|
||||
return mgmt, err
|
||||
}
|
||||
}
|
||||
|
||||
// update the values
|
||||
mgmt.update()
|
||||
|
||||
// Minimum approach to avoid circular dependency
|
||||
cfg.IsFeatureToggleEnabled = mgmt.IsEnabled
|
||||
return mgmt, nil
|
||||
}
|
||||
|
||||
// ProvideToggles allows read-only access to the feature state
|
||||
func ProvideToggles(mgmt *FeatureManager) FeatureToggles {
|
||||
return mgmt
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFeatureService(t *testing.T) {
|
||||
license := stubLicenseServier{
|
||||
flags: []FeatureFlag{
|
||||
{
|
||||
Name: "a.yes.default",
|
||||
RequiresLicense: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "a.yes",
|
||||
RequiresLicense: true,
|
||||
Expression: "",
|
||||
},
|
||||
{
|
||||
Name: "b.no",
|
||||
RequiresLicense: true,
|
||||
},
|
||||
},
|
||||
enabled: map[string]bool{
|
||||
"a.yes.default": true,
|
||||
"a.yes": true,
|
||||
},
|
||||
}
|
||||
require.False(t, license.FeatureEnabled("unknown"))
|
||||
require.False(t, license.FeatureEnabled("b.no"))
|
||||
require.True(t, license.FeatureEnabled("a.yes"))
|
||||
require.True(t, license.FeatureEnabled("a.yes.default"))
|
||||
|
||||
cfg := setting.NewCfg()
|
||||
mgmt, err := ProvideManagerService(cfg, license)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, mgmt)
|
||||
|
||||
// Enterprise features do not fall though automatically
|
||||
require.False(t, mgmt.IsEnabled("a.yes.default"))
|
||||
require.False(t, mgmt.IsEnabled("a.yes")) // licensed, but not enabled
|
||||
}
|
||||
|
||||
var (
|
||||
_ models.Licensing = (*stubLicenseServier)(nil)
|
||||
)
|
||||
|
||||
type stubLicenseServier struct {
|
||||
flags []FeatureFlag
|
||||
enabled map[string]bool
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) Expiry() int64 {
|
||||
return 100
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) Edition() string {
|
||||
return "test"
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) ContentDeliveryPrefix() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) LicenseURL(showAdminLicensingPage bool) string {
|
||||
return "http://??"
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) StateInfo() string {
|
||||
return "ok"
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) EnabledFeatures() map[string]bool {
|
||||
return map[string]bool{}
|
||||
}
|
||||
|
||||
func (s stubLicenseServier) FeatureEnabled(feature string) bool {
|
||||
return s.enabled[feature]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type configBody struct {
|
||||
// define variables that can be used in expressions
|
||||
Vars map[string]interface{} `yaml:"vars"`
|
||||
|
||||
// Define and override feature flag properties
|
||||
Flags []FeatureFlag `yaml:"flags"`
|
||||
|
||||
// keep track of where the fie was loaded from
|
||||
filename string
|
||||
}
|
||||
|
||||
// will read a single configfile
|
||||
func readConfigFile(filename string) (*configBody, error) {
|
||||
cfg := &configBody{}
|
||||
|
||||
// Can ignore gosec G304 because the file path is forced within config subfolder
|
||||
//nolint:gosec
|
||||
yamlFile, err := ioutil.ReadFile(filename)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
err = yaml.Unmarshal(yamlFile, cfg)
|
||||
cfg.filename = filename
|
||||
return cfg, err
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestReadingFeatureSettings(t *testing.T) {
|
||||
config, err := readConfigFile("testdata/features.yaml")
|
||||
require.NoError(t, err, "No error when reading feature configs")
|
||||
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
"level": "free",
|
||||
"stack": "something",
|
||||
"valA": "value from features.yaml",
|
||||
}, config.Vars)
|
||||
|
||||
out, err := yaml.Marshal(config)
|
||||
require.NoError(t, err)
|
||||
fmt.Printf("%s", string(out))
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
include:
|
||||
- included.yaml # not yet supported
|
||||
|
||||
vars:
|
||||
stack: something
|
||||
level: free
|
||||
valA: value from features.yaml
|
||||
|
||||
flags:
|
||||
- name: feature1
|
||||
description: feature1
|
||||
expression: "false"
|
||||
|
||||
- name: feature3
|
||||
description: feature3
|
||||
expression: "true"
|
||||
|
||||
- name: feature3
|
||||
description: feature3
|
||||
expression: env.level == 'free'
|
||||
|
||||
- name: displaySwedishTheme
|
||||
description: enable swedish background theme
|
||||
expression: |
|
||||
// restrict to users allowing swedish language
|
||||
req.locale.contains("sv")
|
||||
- name: displayFrenchFlag
|
||||
description: sho background theme
|
||||
expression: |
|
||||
// only admins
|
||||
user.id == 1
|
||||
// show to users allowing french language
|
||||
&& req.locale.contains("fr")
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
include:
|
||||
- features.yaml # make sure we avoid recusion!
|
||||
|
||||
# variables that can be used in expressions
|
||||
vars:
|
||||
stack: something
|
||||
deep: 1
|
||||
valA: value from included.yaml
|
||||
|
||||
flags:
|
||||
- name: featureFromIncludedFile
|
||||
description: an inlcuded file
|
||||
expression: invalid expression string here
|
||||
@@ -0,0 +1,85 @@
|
||||
// NOTE: This file is autogenerated
|
||||
|
||||
package featuremgmt
|
||||
|
||||
const (
|
||||
// FlagTrimDefaults
|
||||
// Use cue schema to remove values that will be applied automatically
|
||||
FlagTrimDefaults = "trimDefaults"
|
||||
|
||||
// FlagEnvelopeEncryption
|
||||
// encrypt secrets
|
||||
FlagEnvelopeEncryption = "envelopeEncryption"
|
||||
|
||||
// FlagHttpclientproviderAzureAuth
|
||||
// use http client for azure auth
|
||||
FlagHttpclientproviderAzureAuth = "httpclientprovider_azure_auth"
|
||||
|
||||
// FlagServiceAccounts
|
||||
// support service accounts
|
||||
FlagServiceAccounts = "service-accounts"
|
||||
|
||||
// FlagDatabaseMetrics
|
||||
// Add prometheus metrics for database tables
|
||||
FlagDatabaseMetrics = "database_metrics"
|
||||
|
||||
// FlagDashboardPreviews
|
||||
// Create and show thumbnails for dashboard search results
|
||||
FlagDashboardPreviews = "dashboardPreviews"
|
||||
|
||||
// FlagLiveConfig
|
||||
// Save grafana live configuration in SQL tables
|
||||
FlagLiveConfig = "live-config"
|
||||
|
||||
// FlagLivePipeline
|
||||
// enable a generic live processing pipeline
|
||||
FlagLivePipeline = "live-pipeline"
|
||||
|
||||
// FlagLiveServiceWebWorker
|
||||
// This will use a webworker thread to processes events rather than the main thread
|
||||
FlagLiveServiceWebWorker = "live-service-web-worker"
|
||||
|
||||
// FlagQueryOverLive
|
||||
// Use grafana live websocket to execute backend queries
|
||||
FlagQueryOverLive = "queryOverLive"
|
||||
|
||||
// FlagTempoSearch
|
||||
// Enable searching in tempo datasources
|
||||
FlagTempoSearch = "tempoSearch"
|
||||
|
||||
// FlagTempoBackendSearch
|
||||
// Use backend for tempo search
|
||||
FlagTempoBackendSearch = "tempoBackendSearch"
|
||||
|
||||
// FlagTempoServiceGraph
|
||||
// show service
|
||||
FlagTempoServiceGraph = "tempoServiceGraph"
|
||||
|
||||
// FlagFullRangeLogsVolume
|
||||
// Show full range logs volume in explore
|
||||
FlagFullRangeLogsVolume = "fullRangeLogsVolume"
|
||||
|
||||
// FlagAccesscontrol
|
||||
// Support robust access control
|
||||
FlagAccesscontrol = "accesscontrol"
|
||||
|
||||
// FlagPrometheusAzureAuth
|
||||
// Use azure authentication for prometheus datasource
|
||||
FlagPrometheusAzureAuth = "prometheus_azure_auth"
|
||||
|
||||
// FlagNewNavigation
|
||||
// Try the next gen navigation model
|
||||
FlagNewNavigation = "newNavigation"
|
||||
|
||||
// FlagShowFeatureFlagsInUI
|
||||
// Show feature flags in the settings UI
|
||||
FlagShowFeatureFlagsInUI = "showFeatureFlagsInUI"
|
||||
|
||||
// FlagDisableHttpRequestHistogram
|
||||
// Do not create histograms for http requests
|
||||
FlagDisableHttpRequestHistogram = "disable_http_request_histogram"
|
||||
|
||||
// FlagValidatedQueries
|
||||
// only execute the query saved in a panel
|
||||
FlagValidatedQueries = "validatedQueries"
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestFeatureToggleFiles(t *testing.T) {
|
||||
// Typescript files
|
||||
verifyAndGenerateFile(t,
|
||||
"../../../packages/grafana-data/src/types/featureToggles.gen.ts",
|
||||
generateTypeScript(),
|
||||
)
|
||||
|
||||
// Golang files
|
||||
verifyAndGenerateFile(t,
|
||||
"toggles_gen.go",
|
||||
generateRegistry(t),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyAndGenerateFile(t *testing.T, fpath string, gen string) {
|
||||
// nolint:gosec
|
||||
// We can ignore the gosec G304 warning since this is a test and the function is only called explicitly above
|
||||
body, err := ioutil.ReadFile(fpath)
|
||||
if err == nil {
|
||||
if diff := cmp.Diff(gen, string(body)); diff != "" {
|
||||
str := fmt.Sprintf("body mismatch (-want +got):\n%s\n", diff)
|
||||
err = fmt.Errorf(str)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
e2 := os.WriteFile(fpath, []byte(gen), 0644)
|
||||
if e2 != nil {
|
||||
t.Errorf("error writing file: %s", e2.Error())
|
||||
}
|
||||
abs, _ := filepath.Abs(fpath)
|
||||
t.Errorf("feature toggle do not match: %s (%s)", err.Error(), abs)
|
||||
t.Fail()
|
||||
}
|
||||
}
|
||||
|
||||
func generateTypeScript() string {
|
||||
buf := `// NOTE: This file was auto generated. DO NOT EDIT DIRECTLY!
|
||||
// To change feature flags, edit:
|
||||
// pkg/services/featuremgmt/registry.go
|
||||
// Then run tests in:
|
||||
// pkg/services/featuremgmt/toggles_gen_test.go
|
||||
|
||||
/**
|
||||
* Describes available feature toggles in Grafana. These can be configured via
|
||||
* conf/custom.ini to enable features under development or not yet available in
|
||||
* stable version.
|
||||
*
|
||||
* Only enabled values will be returned in this interface
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FeatureToggles {
|
||||
[name: string]: boolean | undefined; // support any string value
|
||||
|
||||
`
|
||||
for _, flag := range standardFeatureFlags {
|
||||
buf += " " + getTypeScriptKey(flag.Name) + "?: boolean;\n"
|
||||
}
|
||||
|
||||
buf += "}\n"
|
||||
return buf
|
||||
}
|
||||
|
||||
func getTypeScriptKey(key string) string {
|
||||
if strings.Contains(key, "-") || strings.Contains(key, ".") {
|
||||
return "['" + key + "']"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func isLetterOrNumber(c rune) bool {
|
||||
return !unicode.IsLetter(c) && !unicode.IsNumber(c)
|
||||
}
|
||||
|
||||
func asCamelCase(key string) string {
|
||||
parts := strings.FieldsFunc(key, isLetterOrNumber)
|
||||
for idx, part := range parts {
|
||||
parts[idx] = strings.Title(part)
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func generateRegistry(t *testing.T) string {
|
||||
tmpl, err := template.New("fn").Parse(`
|
||||
{{"\t"}}// Flag{{.CamelCase}}{{.Ext}}
|
||||
{{"\t"}}Flag{{.CamelCase}} = "{{.Flag.Name}}"
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal("error reading template", "error", err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
data := struct {
|
||||
CamelCase string
|
||||
Flag FeatureFlag
|
||||
Ext string
|
||||
}{
|
||||
CamelCase: "?",
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
|
||||
buff.WriteString(`// NOTE: This file is autogenerated
|
||||
|
||||
package featuremgmt
|
||||
|
||||
const (`)
|
||||
|
||||
for _, flag := range standardFeatureFlags {
|
||||
data.CamelCase = asCamelCase(flag.Name)
|
||||
data.Flag = flag
|
||||
data.Ext = ""
|
||||
|
||||
if flag.Description != "" {
|
||||
data.Ext += "\n\t// " + flag.Description
|
||||
}
|
||||
|
||||
_ = tmpl.Execute(&buff, data)
|
||||
}
|
||||
buff.WriteString(")\n")
|
||||
|
||||
return buff.String()
|
||||
}
|
||||
Reference in New Issue
Block a user