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