mirror of https://github.com/grafana/grafana
FeatureFlags: manage feature flags outside of settings.Cfg (#43692)
parent
7fbc7d019a
commit
f94c0decbd
@ -1,6 +1,12 @@ |
||||
import { FeatureToggles } from '@grafana/data'; |
||||
import { config } from '../config'; |
||||
|
||||
export const featureEnabled = (feature: string): boolean => { |
||||
const { enabledFeatures } = config.licenseInfo; |
||||
return enabledFeatures && enabledFeatures[feature]; |
||||
export const featureEnabled = (feature: boolean | undefined | keyof FeatureToggles): boolean => { |
||||
if (feature === true || feature === false) { |
||||
return feature; |
||||
} |
||||
if (feature == null || !config?.featureToggles) { |
||||
return false; |
||||
} |
||||
return Boolean(config.featureToggles[feature]); |
||||
}; |
||||
|
||||
@ -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)) |
||||
} |
||||
@ -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") |
||||
@ -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() |
||||
} |
||||
Loading…
Reference in new issue