FeatureFlags: define features outside settings.Cfg (take 3) (#44443)

This commit is contained in:
Ryan McKinley
2022-01-26 09:44:20 -08:00
committed by GitHub
parent 84a5910e56
commit 5d66194ec5
64 changed files with 1193 additions and 248 deletions
+96
View File
@@ -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
}
+185
View File
@@ -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}
}
+77
View File
@@ -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)
})
}
+118
View File
@@ -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,
},
}
)
+76
View File
@@ -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
}
+84
View File
@@ -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]
}
+34
View File
@@ -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
}
+25
View File
@@ -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
View File
@@ -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
View File
@@ -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
+85
View File
@@ -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()
}