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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user