mirror of https://github.com/grafana/grafana
Add OpenFeature providers (#101071)
* Add OpenFeature provider * Simplify static provider * Linting * Apply review feedback * Codeowner for deps * Update pkg/services/featuremgmt/openfeature.go Co-authored-by: Dave Henderson <dave.henderson@grafana.com> * Apply review comments part2 * Remove from sample.ini for now * fixup! Apply review comments part2 * Add example of context config * Resolve conflict * Run update workspaces --------- Co-authored-by: Dave Henderson <dave.henderson@grafana.com>pull/101264/head^2
parent
b6221cc389
commit
c9ab1142e1
@ -0,0 +1,21 @@ |
||||
package featuremgmt |
||||
|
||||
import ( |
||||
"net/http" |
||||
"time" |
||||
|
||||
gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" |
||||
"github.com/open-feature/go-sdk/openfeature" |
||||
) |
||||
|
||||
func newGOFFProvider(url string) (openfeature.FeatureProvider, error) { |
||||
options := gofeatureflag.ProviderOptions{ |
||||
Endpoint: url, |
||||
// consider using github.com/grafana/grafana/pkg/infra/httpclient/provider.go
|
||||
HTTPClient: &http.Client{ |
||||
Timeout: 10 * time.Second, |
||||
}, |
||||
} |
||||
provider, err := gofeatureflag.NewProvider(options) |
||||
return provider, err |
||||
} |
||||
@ -0,0 +1,71 @@ |
||||
package featuremgmt |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/open-feature/go-sdk/openfeature" |
||||
) |
||||
|
||||
const ( |
||||
staticProviderType = "static" |
||||
goffProviderType = "goff" |
||||
|
||||
configSectionName = "feature_toggles.openfeature" |
||||
contextSectionName = "feature_toggles.openfeature.context" |
||||
) |
||||
|
||||
type OpenFeatureService struct { |
||||
provider openfeature.FeatureProvider |
||||
Client openfeature.IClient |
||||
} |
||||
|
||||
func ProvideOpenFeatureService(cfg *setting.Cfg) (*OpenFeatureService, error) { |
||||
conf := cfg.Raw.Section(configSectionName) |
||||
provType := conf.Key("provider").MustString(staticProviderType) |
||||
url := conf.Key("url").MustString("") |
||||
key := conf.Key("targetingKey").MustString(cfg.AppURL) |
||||
|
||||
var provider openfeature.FeatureProvider |
||||
var err error |
||||
if provType == goffProviderType { |
||||
provider, err = newGOFFProvider(url) |
||||
} else { |
||||
provider, err = newStaticProvider(cfg) |
||||
} |
||||
|
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to create %s feature provider: %w", provType, err) |
||||
} |
||||
|
||||
if err := openfeature.SetProviderAndWait(provider); err != nil { |
||||
return nil, fmt.Errorf("failed to set global %s feature provider: %w", provType, err) |
||||
} |
||||
|
||||
attrs := ctxAttrs(cfg) |
||||
openfeature.SetEvaluationContext(openfeature.NewEvaluationContext(key, attrs)) |
||||
|
||||
client := openfeature.NewClient("grafana-openfeature-client") |
||||
|
||||
return &OpenFeatureService{ |
||||
provider: provider, |
||||
Client: client, |
||||
}, nil |
||||
} |
||||
|
||||
// ctxAttrs uses config.ini [feature_toggles.openfeature.context] section to build the eval context attributes
|
||||
func ctxAttrs(cfg *setting.Cfg) map[string]any { |
||||
ctxConf := cfg.Raw.Section(contextSectionName) |
||||
|
||||
attrs := map[string]any{} |
||||
for _, key := range ctxConf.KeyStrings() { |
||||
attrs[key] = ctxConf.Key(key).String() |
||||
} |
||||
|
||||
// Some default attributes
|
||||
if _, ok := attrs["grafana_version"]; !ok { |
||||
attrs["grafana_version"] = setting.BuildVersion |
||||
} |
||||
|
||||
return attrs |
||||
} |
||||
@ -0,0 +1,113 @@ |
||||
package featuremgmt |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/setting" |
||||
|
||||
gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" |
||||
"github.com/open-feature/go-sdk/openfeature/memprovider" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestProvideOpenFeatureManager(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
cfg string |
||||
expectedProvider string |
||||
}{ |
||||
{ |
||||
name: "static provider", |
||||
expectedProvider: staticProviderType, |
||||
}, |
||||
{ |
||||
name: "goff provider", |
||||
cfg: ` |
||||
[feature_toggles.openfeature] |
||||
provider = goff |
||||
url = http://localhost:1031
|
||||
targetingKey = grafana |
||||
`, |
||||
expectedProvider: goffProviderType, |
||||
}, |
||||
{ |
||||
name: "invalid provider", |
||||
cfg: ` |
||||
[feature_toggles.openfeature] |
||||
provider = some_provider |
||||
`, |
||||
expectedProvider: staticProviderType, |
||||
}, |
||||
} |
||||
|
||||
for _, tc := range testCases { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
cfg := setting.NewCfg() |
||||
if tc.cfg != "" { |
||||
err := cfg.Raw.Append([]byte(tc.cfg)) |
||||
require.NoError(t, err) |
||||
} |
||||
|
||||
p, err := ProvideOpenFeatureService(cfg) |
||||
require.NoError(t, err) |
||||
|
||||
if tc.expectedProvider == goffProviderType { |
||||
_, ok := p.provider.(*gofeatureflag.Provider) |
||||
assert.True(t, ok, "expected provider to be of type goff.Provider") |
||||
} else { |
||||
_, ok := p.provider.(memprovider.InMemoryProvider) |
||||
assert.True(t, ok, "expected provider to be of type memprovider.InMemoryProvider") |
||||
} |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func Test_CtxAttrs(t *testing.T) { |
||||
testCases := []struct { |
||||
name string |
||||
conf string |
||||
expected map[string]any |
||||
}{ |
||||
{ |
||||
name: "empty config - only default attributes should be present", |
||||
expected: map[string]any{ |
||||
"grafana_version": "", |
||||
}, |
||||
}, |
||||
{ |
||||
name: "config with some attributes", |
||||
conf: ` |
||||
[feature_toggles.openfeature.context] |
||||
foo = bar |
||||
baz = qux |
||||
quux = corge`, |
||||
expected: map[string]any{ |
||||
"foo": "bar", |
||||
"baz": "qux", |
||||
"quux": "corge", |
||||
"grafana_version": "", |
||||
}, |
||||
}, |
||||
{ |
||||
name: "config with an attribute that overrides a default one", |
||||
conf: ` |
||||
[feature_toggles.openfeature.context] |
||||
grafana_version = 10.0.0 |
||||
foo = bar`, |
||||
expected: map[string]any{ |
||||
"grafana_version": "10.0.0", |
||||
"foo": "bar", |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
for _, tc := range testCases { |
||||
t.Run(tc.name, func(t *testing.T) { |
||||
cfg, err := setting.NewCfgFromBytes([]byte(tc.conf)) |
||||
require.NoError(t, err) |
||||
|
||||
assert.Equal(t, tc.expected, ctxAttrs(cfg)) |
||||
}) |
||||
} |
||||
} |
||||
@ -0,0 +1,49 @@ |
||||
package featuremgmt |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/open-feature/go-sdk/openfeature" |
||||
"github.com/open-feature/go-sdk/openfeature/memprovider" |
||||
) |
||||
|
||||
func newStaticProvider(cfg *setting.Cfg) (openfeature.FeatureProvider, error) { |
||||
confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles")) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to read feature toggles from config: %w", err) |
||||
} |
||||
|
||||
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)) |
||||
|
||||
// Add flags from config.ini file
|
||||
for name, value := range confFlags { |
||||
flags[name] = createInMemoryFlag(name, value) |
||||
} |
||||
|
||||
// Add standard flags
|
||||
for _, flag := range standardFeatureFlags { |
||||
if _, exists := flags[flag.Name]; !exists { |
||||
enabled := flag.Expression == "true" |
||||
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled) |
||||
} |
||||
} |
||||
|
||||
return memprovider.NewInMemoryProvider(flags), nil |
||||
} |
||||
|
||||
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { |
||||
variant := "disabled" |
||||
if enabled { |
||||
variant = "enabled" |
||||
} |
||||
|
||||
return memprovider.InMemoryFlag{ |
||||
Key: name, |
||||
DefaultVariant: variant, |
||||
Variants: map[string]interface{}{ |
||||
"enabled": true, |
||||
"disabled": false, |
||||
}, |
||||
} |
||||
} |
||||
@ -0,0 +1,58 @@ |
||||
package featuremgmt |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/setting" |
||||
|
||||
"github.com/open-feature/go-sdk/openfeature" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func Test_StaticProvider(t *testing.T) { |
||||
ctx := context.Background() |
||||
evalCtx := openfeature.NewEvaluationContext("grafana", nil) |
||||
|
||||
stFeat := standardFeatureFlags[0] |
||||
stFeatName := stFeat.Name |
||||
stFeatValue := stFeat.Expression == "true" |
||||
|
||||
t.Run("empty config loads standard flags", func(t *testing.T) { |
||||
p := provider(t, []byte(``)) |
||||
// Check for one of the standard flags
|
||||
feat, err := p.Client.BooleanValueDetails(ctx, stFeatName, !stFeatValue, evalCtx) |
||||
assert.NoError(t, err) |
||||
assert.True(t, stFeatValue == feat.Value) |
||||
}) |
||||
|
||||
t.Run("featureOne does not exist in standard flags but should be loaded", func(t *testing.T) { |
||||
conf := []byte(` |
||||
[feature_toggles] |
||||
featureOne = true |
||||
`) |
||||
p := provider(t, conf) |
||||
feat, err := p.Client.BooleanValueDetails(ctx, "featureOne", false, evalCtx) |
||||
assert.NoError(t, err) |
||||
assert.True(t, feat.Value) |
||||
}) |
||||
|
||||
t.Run("missing feature should return default evaluation value and an error", func(t *testing.T) { |
||||
p := provider(t, []byte(``)) |
||||
missingFeature, err := p.Client.BooleanValueDetails(ctx, "missingFeature", true, evalCtx) |
||||
assert.Error(t, err) |
||||
assert.True(t, missingFeature.Value) |
||||
assert.Equal(t, openfeature.ErrorCode("FLAG_NOT_FOUND"), missingFeature.ErrorCode) |
||||
}) |
||||
} |
||||
|
||||
func provider(t *testing.T, conf []byte) *OpenFeatureService { |
||||
t.Helper() |
||||
cfg, err := setting.NewCfgFromBytes(conf) |
||||
require.NoError(t, err) |
||||
|
||||
p, err := ProvideOpenFeatureService(cfg) |
||||
require.NoError(t, err) |
||||
return p |
||||
} |
||||
Loading…
Reference in new issue