mirror of
https://github.com/grafana/grafana.git
synced 2026-08-18 17:15:08 -05:00
FeatureFlags: manage feature flags outside of settings.Cfg (#43692)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package featuremgmt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// FeatureToggleState indicates the quality level
|
||||
type FeatureToggleState int
|
||||
|
||||
const (
|
||||
// FeatureStateUnknown indicates that no state is specified
|
||||
FeatureStateUnknown FeatureToggleState = 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 FeatureToggleState) 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 FeatureToggleState) 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 *FeatureToggleState) 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 FeatureToggleState `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
|
||||
|
||||
// Internal properties
|
||||
// expr string `json:-`
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type FeatureManager struct {
|
||||
isDevMod bool
|
||||
licensing models.Licensing
|
||||
flags map[string]*FeatureFlag
|
||||
enabled map[string]bool // only the "on" values
|
||||
toggles *FeatureToggles
|
||||
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 idx, add := range flags {
|
||||
if add.Name == "" {
|
||||
continue // skip it with warning?
|
||||
}
|
||||
flag, ok := fm.flags[add.Name]
|
||||
if !ok {
|
||||
fm.flags[add.Name] = &flags[idx]
|
||||
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
|
||||
}
|
||||
|
||||
// Toggles returns FeatureToggles.
|
||||
func (fm *FeatureManager) Toggles() *FeatureToggles {
|
||||
if fm.toggles == nil {
|
||||
fm.toggles = &FeatureToggles{manager: fm}
|
||||
}
|
||||
return fm.toggles
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
func WithToggles(spec ...interface{}) *FeatureToggles {
|
||||
return &FeatureToggles{
|
||||
manager: WithFeatures(spec...),
|
||||
}
|
||||
}
|
||||
@@ -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,163 @@
|
||||
package featuremgmt
|
||||
|
||||
import "github.com/grafana/grafana/pkg/services/secrets"
|
||||
|
||||
var (
|
||||
FLAG_database_metrics = "database_metrics"
|
||||
FLAG_live_config = "live-config"
|
||||
FLAG_recordedQueries = "recordedQueries"
|
||||
|
||||
// Register each toggle here
|
||||
standardFeatureFlags = []FeatureFlag{
|
||||
{
|
||||
Name: FLAG_recordedQueries,
|
||||
Description: "Supports saving queries that can be scraped by prometheus",
|
||||
State: FeatureStateBeta,
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "teamsync",
|
||||
Description: "Team sync lets you set up synchronization between your auth providers teams and teams in Grafana",
|
||||
State: FeatureStateStable,
|
||||
DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/team-sync/",
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "ldapsync",
|
||||
Description: "Enhanced LDAP integration",
|
||||
State: FeatureStateStable,
|
||||
DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/enhanced_ldap/",
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "caching",
|
||||
Description: "Temporarily store data source query results.",
|
||||
State: FeatureStateStable,
|
||||
DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/query-caching/",
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "dspermissions",
|
||||
Description: "Data source permissions",
|
||||
State: FeatureStateStable,
|
||||
DocsURL: "https://grafana.com/docs/grafana/latest/enterprise/datasource_permissions/",
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "analytics",
|
||||
Description: "Analytics",
|
||||
State: FeatureStateStable,
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "enterprise.plugins",
|
||||
Description: "Enterprise plugins",
|
||||
State: FeatureStateStable,
|
||||
DocsURL: "https://grafana.com/grafana/plugins/?enterprise=1",
|
||||
RequiresLicense: true,
|
||||
},
|
||||
{
|
||||
Name: "trimDefaults",
|
||||
Description: "Use cue schema to remove values that will be applied automatically",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: secrets.EnvelopeEncryptionFeatureToggle,
|
||||
Description: "encrypt secrets",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
|
||||
{
|
||||
Name: "httpclientprovider_azure_auth",
|
||||
State: FeatureStateBeta,
|
||||
},
|
||||
{
|
||||
Name: "service-accounts",
|
||||
Description: "support service accounts",
|
||||
State: FeatureStateBeta,
|
||||
RequiresLicense: true,
|
||||
},
|
||||
|
||||
{
|
||||
Name: FLAG_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: FLAG_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 expore",
|
||||
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 naviation model",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "showFeatureFlagsInUI",
|
||||
Description: "Show feature flags in the settings UI",
|
||||
State: FeatureStateAlpha,
|
||||
RequiresDevMode: true,
|
||||
},
|
||||
{
|
||||
Name: "disable_http_request_histogram",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
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 &FeatureToggles{
|
||||
manager: mgmt,
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@
|
||||
package featuremgmt
|
||||
|
||||
type FeatureToggles struct {
|
||||
manager *FeatureManager
|
||||
}
|
||||
|
||||
// IsEnabled checks if a feature is enabled
|
||||
func (ft *FeatureToggles) IsEnabled(flag string) bool {
|
||||
return ft.manager.IsEnabled(flag)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// NOTE: This file is autogenerated
|
||||
|
||||
package featuremgmt
|
||||
|
||||
// IsRecordedQueriesEnabled checks for the flag: recordedQueries
|
||||
// Supports saving queries that can be scraped by prometheus
|
||||
func (ft *FeatureToggles) IsRecordedQueriesEnabled() bool {
|
||||
return ft.manager.IsEnabled("recordedQueries")
|
||||
}
|
||||
|
||||
// IsTeamsyncEnabled checks for the flag: teamsync
|
||||
// Team sync lets you set up synchronization between your auth providers teams and teams in Grafana
|
||||
func (ft *FeatureToggles) IsTeamsyncEnabled() bool {
|
||||
return ft.manager.IsEnabled("teamsync")
|
||||
}
|
||||
|
||||
// IsLdapsyncEnabled checks for the flag: ldapsync
|
||||
// Enhanced LDAP integration
|
||||
func (ft *FeatureToggles) IsLdapsyncEnabled() bool {
|
||||
return ft.manager.IsEnabled("ldapsync")
|
||||
}
|
||||
|
||||
// IsCachingEnabled checks for the flag: caching
|
||||
// Temporarily store data source query results.
|
||||
func (ft *FeatureToggles) IsCachingEnabled() bool {
|
||||
return ft.manager.IsEnabled("caching")
|
||||
}
|
||||
|
||||
// IsDspermissionsEnabled checks for the flag: dspermissions
|
||||
// Data source permissions
|
||||
func (ft *FeatureToggles) IsDspermissionsEnabled() bool {
|
||||
return ft.manager.IsEnabled("dspermissions")
|
||||
}
|
||||
|
||||
// IsAnalyticsEnabled checks for the flag: analytics
|
||||
// Analytics
|
||||
func (ft *FeatureToggles) IsAnalyticsEnabled() bool {
|
||||
return ft.manager.IsEnabled("analytics")
|
||||
}
|
||||
|
||||
// IsEnterprisePluginsEnabled checks for the flag: enterprise.plugins
|
||||
// Enterprise plugins
|
||||
func (ft *FeatureToggles) IsEnterprisePluginsEnabled() bool {
|
||||
return ft.manager.IsEnabled("enterprise.plugins")
|
||||
}
|
||||
|
||||
// IsTrimDefaultsEnabled checks for the flag: trimDefaults
|
||||
// Use cue schema to remove values that will be applied automatically
|
||||
func (ft *FeatureToggles) IsTrimDefaultsEnabled() bool {
|
||||
return ft.manager.IsEnabled("trimDefaults")
|
||||
}
|
||||
|
||||
// IsEnvelopeEncryptionEnabled checks for the flag: envelopeEncryption
|
||||
// encrypt secrets
|
||||
func (ft *FeatureToggles) IsEnvelopeEncryptionEnabled() bool {
|
||||
return ft.manager.IsEnabled("envelopeEncryption")
|
||||
}
|
||||
|
||||
// IsHttpclientproviderAzureAuthEnabled checks for the flag: httpclientprovider_azure_auth
|
||||
func (ft *FeatureToggles) IsHttpclientproviderAzureAuthEnabled() bool {
|
||||
return ft.manager.IsEnabled("httpclientprovider_azure_auth")
|
||||
}
|
||||
|
||||
// IsServiceAccountsEnabled checks for the flag: service-accounts
|
||||
// support service accounts
|
||||
func (ft *FeatureToggles) IsServiceAccountsEnabled() bool {
|
||||
return ft.manager.IsEnabled("service-accounts")
|
||||
}
|
||||
|
||||
// IsDatabaseMetricsEnabled checks for the flag: database_metrics
|
||||
// Add prometheus metrics for database tables
|
||||
func (ft *FeatureToggles) IsDatabaseMetricsEnabled() bool {
|
||||
return ft.manager.IsEnabled("database_metrics")
|
||||
}
|
||||
|
||||
// IsDashboardPreviewsEnabled checks for the flag: dashboardPreviews
|
||||
// Create and show thumbnails for dashboard search results
|
||||
func (ft *FeatureToggles) IsDashboardPreviewsEnabled() bool {
|
||||
return ft.manager.IsEnabled("dashboardPreviews")
|
||||
}
|
||||
|
||||
// IsLiveConfigEnabled checks for the flag: live-config
|
||||
// Save grafana live configuration in SQL tables
|
||||
func (ft *FeatureToggles) IsLiveConfigEnabled() bool {
|
||||
return ft.manager.IsEnabled("live-config")
|
||||
}
|
||||
|
||||
// IsLivePipelineEnabled checks for the flag: live-pipeline
|
||||
// enable a generic live processing pipeline
|
||||
func (ft *FeatureToggles) IsLivePipelineEnabled() bool {
|
||||
return ft.manager.IsEnabled("live-pipeline")
|
||||
}
|
||||
|
||||
// IsLiveServiceWebWorkerEnabled checks for the flag: live-service-web-worker
|
||||
// This will use a webworker thread to processes events rather than the main thread
|
||||
func (ft *FeatureToggles) IsLiveServiceWebWorkerEnabled() bool {
|
||||
return ft.manager.IsEnabled("live-service-web-worker")
|
||||
}
|
||||
|
||||
// IsQueryOverLiveEnabled checks for the flag: queryOverLive
|
||||
// Use grafana live websocket to execute backend queries
|
||||
func (ft *FeatureToggles) IsQueryOverLiveEnabled() bool {
|
||||
return ft.manager.IsEnabled("queryOverLive")
|
||||
}
|
||||
|
||||
// IsTempoSearchEnabled checks for the flag: tempoSearch
|
||||
// Enable searching in tempo datasources
|
||||
func (ft *FeatureToggles) IsTempoSearchEnabled() bool {
|
||||
return ft.manager.IsEnabled("tempoSearch")
|
||||
}
|
||||
|
||||
// IsTempoBackendSearchEnabled checks for the flag: tempoBackendSearch
|
||||
// Use backend for tempo search
|
||||
func (ft *FeatureToggles) IsTempoBackendSearchEnabled() bool {
|
||||
return ft.manager.IsEnabled("tempoBackendSearch")
|
||||
}
|
||||
|
||||
// IsTempoServiceGraphEnabled checks for the flag: tempoServiceGraph
|
||||
// show service
|
||||
func (ft *FeatureToggles) IsTempoServiceGraphEnabled() bool {
|
||||
return ft.manager.IsEnabled("tempoServiceGraph")
|
||||
}
|
||||
|
||||
// IsFullRangeLogsVolumeEnabled checks for the flag: fullRangeLogsVolume
|
||||
// Show full range logs volume in expore
|
||||
func (ft *FeatureToggles) IsFullRangeLogsVolumeEnabled() bool {
|
||||
return ft.manager.IsEnabled("fullRangeLogsVolume")
|
||||
}
|
||||
|
||||
// IsAccesscontrolEnabled checks for the flag: accesscontrol
|
||||
// Support robust access control
|
||||
func (ft *FeatureToggles) IsAccesscontrolEnabled() bool {
|
||||
return ft.manager.IsEnabled("accesscontrol")
|
||||
}
|
||||
|
||||
// IsPrometheusAzureAuthEnabled checks for the flag: prometheus_azure_auth
|
||||
// Use azure authentication for prometheus datasource
|
||||
func (ft *FeatureToggles) IsPrometheusAzureAuthEnabled() bool {
|
||||
return ft.manager.IsEnabled("prometheus_azure_auth")
|
||||
}
|
||||
|
||||
// IsNewNavigationEnabled checks for the flag: newNavigation
|
||||
// Try the next gen naviation model
|
||||
func (ft *FeatureToggles) IsNewNavigationEnabled() bool {
|
||||
return ft.manager.IsEnabled("newNavigation")
|
||||
}
|
||||
|
||||
// IsShowFeatureFlagsInUIEnabled checks for the flag: showFeatureFlagsInUI
|
||||
// Show feature flags in the settings UI
|
||||
func (ft *FeatureToggles) IsShowFeatureFlagsInUIEnabled() bool {
|
||||
return ft.manager.IsEnabled("showFeatureFlagsInUI")
|
||||
}
|
||||
|
||||
// IsDisableHttpRequestHistogramEnabled checks for the flag: disable_http_request_histogram
|
||||
func (ft *FeatureToggles) IsDisableHttpRequestHistogramEnabled() bool {
|
||||
return ft.manager.IsEnabled("disable_http_request_histogram")
|
||||
}
|
||||
@@ -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(`
|
||||
// Is{{.CamleCase}}Enabled checks for the flag: {{.Flag.Name}}{{.Ext}}
|
||||
func (ft *FeatureToggles) Is{{.CamleCase}}Enabled() bool {
|
||||
return ft.manager.IsEnabled("{{.Flag.Name}}")
|
||||
}
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal("error reading template", "error", err.Error())
|
||||
return ""
|
||||
}
|
||||
|
||||
data := struct {
|
||||
CamleCase string
|
||||
Flag FeatureFlag
|
||||
Ext string
|
||||
}{
|
||||
CamleCase: "?",
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
|
||||
buff.WriteString(`// NOTE: This file is autogenerated
|
||||
|
||||
package featuremgmt
|
||||
`)
|
||||
|
||||
for _, flag := range standardFeatureFlags {
|
||||
data.CamleCase = asCamelCase(flag.Name)
|
||||
data.Flag = flag
|
||||
data.Ext = ""
|
||||
|
||||
if flag.Description != "" {
|
||||
data.Ext += "\n// " + flag.Description
|
||||
}
|
||||
|
||||
_ = tmpl.Execute(&buff, data)
|
||||
}
|
||||
|
||||
return buff.String()
|
||||
}
|
||||
Reference in New Issue
Block a user