Implement OFREP compatible feature flag service (#105632)

* Add ofrep pkg

* api server: Use namespace from request in case user is not authenticated

* Add handlers to ofrep api builder

* Add NewOpenFeatureService to initialize mt apiserver

* allow specifying CA and insecure

* Compare namespace with eval ctx stackID

* Organize ofrep package

* Implement AllowedV0Alpha1Resources

* Revert folderimpl changes

* Handle default namespace

* Fix extracting stack id from eval ctx

* Add more logs

* Update pkg/registry/apis/ofrep/register.go

Co-authored-by: Dave Henderson <dave.henderson@grafana.com>

* Update pkg/registry/apis/ofrep/register.go

Co-authored-by: Dave Henderson <dave.henderson@grafana.com>

* Apply review feedback

* Replace contexthandler with types

* Fix identifying authed request

* Refactor checks in the handlers

* Remove anonymous from isAuthenticatedRequest check

---------

Co-authored-by: Todd Treece <360020+toddtreece@users.noreply.github.com>
Co-authored-by: Gabriel Mabille <gabriel.mabille@grafana.com>
Co-authored-by: Charandas Batra <charandas.batra@grafana.com>
Co-authored-by: Dave Henderson <dave.henderson@grafana.com>
This commit is contained in:
Tania
2025-06-27 18:30:38 +02:00
committed by GitHub
co-authored by Dave Henderson Todd Treece Gabriel Mabille Charandas Batra
parent 45dabd2862
commit 67a952c34e
13 changed files with 630 additions and 94 deletions
+29 -73
View File
@@ -1,8 +1,8 @@
package featuremgmt
import (
"context"
"fmt"
"net/url"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
@@ -11,102 +11,58 @@ import (
)
type OpenFeatureService struct {
cfg *setting.Cfg
log log.Logger
provider openfeature.FeatureProvider
Client openfeature.IClient
}
// ProvideOpenFeatureService is used for wiring dependencies in single tenant grafana
func ProvideOpenFeatureService(cfg *setting.Cfg) (*OpenFeatureService, error) {
var provider openfeature.FeatureProvider
var err error
if cfg.OpenFeature.ProviderType == setting.GOFFProviderType {
if cfg.OpenFeature.URL == nil {
return nil, fmt.Errorf("feature provider url is required for GOFFProviderType")
}
provider, err = newGOFFProvider(cfg.OpenFeature.URL.String())
} else {
provider, err = newStaticProvider(cfg)
}
confFlags, err := setting.ReadFeatureTogglesFromInitFile(cfg.Raw.Section("feature_toggles"))
if err != nil {
return nil, fmt.Errorf("failed to create %s feature provider: %w", cfg.OpenFeature.ProviderType, err)
}
if err := openfeature.SetProviderAndWait(provider); err != nil {
return nil, fmt.Errorf("failed to set global %s feature provider: %w", cfg.OpenFeature.ProviderType, err)
return nil, fmt.Errorf("failed to read feature toggles from config: %w", err)
}
openfeature.SetEvaluationContext(openfeature.NewEvaluationContext(cfg.OpenFeature.TargetingKey, cfg.OpenFeature.ContextAttrs))
return newOpenFeatureService(cfg.OpenFeature.ProviderType, cfg.OpenFeature.URL, confFlags)
}
// TODO: might need to be public, so other MT services could set up open feature client
func newOpenFeatureService(pType string, u *url.URL, staticFlags map[string]bool) (*OpenFeatureService, error) {
p, err := createProvider(pType, u, staticFlags)
if err != nil {
return nil, fmt.Errorf("failed to create feature provider: type %s, %w", pType, err)
}
if err := openfeature.SetProviderAndWait(p); err != nil {
return nil, fmt.Errorf("failed to set global feature provider: %s, %w", pType, err)
}
client := openfeature.NewClient("grafana-openfeature-client")
return &OpenFeatureService{
cfg: cfg,
log: log.New("openfeatureservice"),
provider: provider,
provider: p,
Client: client,
}, nil
}
func (s *OpenFeatureService) EvalFlagWithStaticProvider(ctx context.Context, flagKey string) (openfeature.BooleanEvaluationDetails, error) {
_, ok := s.provider.(*inMemoryBulkProvider)
if !ok {
return openfeature.BooleanEvaluationDetails{}, fmt.Errorf("not a static provider, request must be sent to open feature service")
func createProvider(providerType string, u *url.URL, staticFlags map[string]bool) (openfeature.FeatureProvider, error) {
if providerType != setting.GOFFProviderType {
return newStaticProvider(staticFlags)
}
result, err := s.Client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
if err != nil {
return openfeature.BooleanEvaluationDetails{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err)
if u.String() == "" {
return nil, fmt.Errorf("feature provider url is required for GOFFProviderType")
}
return result, nil
return newGOFFProvider(u.String())
}
func (s *OpenFeatureService) EvalAllFlagsWithStaticProvider(ctx context.Context) (OFREPBulkResponse, error) {
p, ok := s.provider.(*inMemoryBulkProvider)
if !ok {
return OFREPBulkResponse{}, fmt.Errorf("not a static provider, request must be sent to open feature service")
func createClient(provider openfeature.FeatureProvider) (openfeature.IClient, error) {
if err := openfeature.SetProviderAndWait(provider); err != nil {
return nil, fmt.Errorf("failed to set global feature provider: %w", err)
}
flags, err := p.ListFlags()
if err != nil {
return OFREPBulkResponse{}, fmt.Errorf("static provider failed to list all flags: %w", err)
}
allFlags := make([]OFREPFlag, 0, len(flags))
for _, flagKey := range flags {
result, err := s.Client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
if err != nil {
s.log.Error("failed to evaluate flag during bulk evaluation", "flagKey", flagKey, "error", err)
continue
}
allFlags = append(allFlags, OFREPFlag{
Key: flagKey,
Value: result.Value,
Reason: "static provider evaluation result",
Variant: result.Variant,
ErrorCode: string(result.ErrorCode),
ErrorDetails: result.ErrorMessage,
})
}
return OFREPBulkResponse{Flags: allFlags}, nil
}
// Bulk evaluation response
type OFREPBulkResponse struct {
Flags []OFREPFlag `json:"flags"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type OFREPFlag struct {
Key string `json:"key"`
Value bool `json:"value"`
Reason string `json:"reason"`
Variant string `json:"variant,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorDetails string `json:"errorDetails,omitempty"`
client := openfeature.NewClient("grafana-openfeature-client")
return client, nil
}
@@ -0,0 +1,119 @@
package featuremgmt
import (
"context"
"fmt"
"net/url"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature"
)
// StaticFlagEvaluator provides methods for evaluating static feature flags
// it is only used when static provider is configured
type StaticFlagEvaluator interface {
EvalFlag(ctx context.Context, flagKey string) (openfeature.BooleanEvaluationDetails, error)
EvalAllFlags(ctx context.Context) (OFREPBulkResponse, error)
}
// ProvideStaticEvaluator creates a static evaluator from configuration
// This can be used in wire dependency injection
func ProvideStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
if cfg.OpenFeature.ProviderType == setting.GOFFProviderType {
l := log.New("static-evaluator")
l.Debug("cannot create static evaluator if configured provider is goff")
return &staticEvaluator{}, nil
}
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)
}
return createStaticEvaluator(cfg.OpenFeature.ProviderType, cfg.OpenFeature.URL, confFlags)
}
// createStaticEvaluator evaluator that allows evaluating static flags from config.ini
func createStaticEvaluator(providerType string, u *url.URL, staticFlags map[string]bool) (StaticFlagEvaluator, error) {
provider, err := createProvider(providerType, u, staticFlags)
if err != nil {
return nil, err
}
staticProvider, ok := provider.(*inMemoryBulkProvider)
if !ok {
return nil, fmt.Errorf("provider is not a static provider")
}
client, err := createClient(provider)
if err != nil {
return nil, err
}
return &staticEvaluator{
provider: staticProvider,
client: client,
log: log.New("static-evaluator"),
}, nil
}
// staticEvaluator implements StaticFlagEvaluator for static providers
type staticEvaluator struct {
provider *inMemoryBulkProvider
client openfeature.IClient
log log.Logger
}
func (s *staticEvaluator) EvalFlag(ctx context.Context, flagKey string) (openfeature.BooleanEvaluationDetails, error) {
result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
if err != nil {
return openfeature.BooleanEvaluationDetails{}, fmt.Errorf("failed to evaluate flag %s: %w", flagKey, err)
}
return result, nil
}
func (s *staticEvaluator) EvalAllFlags(ctx context.Context) (OFREPBulkResponse, error) {
flags, err := s.provider.ListFlags()
if err != nil {
return OFREPBulkResponse{}, fmt.Errorf("static provider failed to list all flags: %w", err)
}
allFlags := make([]OFREPFlag, 0, len(flags))
for _, flagKey := range flags {
result, err := s.client.BooleanValueDetails(ctx, flagKey, false, openfeature.TransactionContext(ctx))
if err != nil {
s.log.Error("failed to evaluate flag during bulk evaluation", "flagKey", flagKey, "error", err)
continue
}
allFlags = append(allFlags, OFREPFlag{
Key: flagKey,
Value: result.Value,
Reason: "static provider evaluation result",
Variant: result.Variant,
ErrorCode: string(result.ErrorCode),
ErrorDetails: result.ErrorMessage,
})
}
return OFREPBulkResponse{Flags: allFlags}, nil
}
// OFREPBulkResponse represents the response for bulk flag evaluation
type OFREPBulkResponse struct {
Flags []OFREPFlag `json:"flags"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// OFREPFlag represents a single flag in the bulk response
type OFREPFlag struct {
Key string `json:"key"`
Value bool `json:"value"`
Reason string `json:"reason"`
Variant string `json:"variant,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
ErrorDetails string `json:"errorDetails,omitempty"`
}
+1 -9
View File
@@ -1,9 +1,6 @@
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"
)
@@ -31,12 +28,7 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
return keys, nil
}
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)
}
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
// Add flags from config.ini file
@@ -20,7 +20,7 @@ func Test_StaticProvider(t *testing.T) {
stFeatValue := stFeat.Expression == "true"
t.Run("empty config loads standard flags", func(t *testing.T) {
p := provider(t, []byte(``))
p := setup(t, []byte(``))
// Check for one of the standard flags
feat, err := p.Client.BooleanValueDetails(ctx, stFeatName, !stFeatValue, evalCtx)
assert.NoError(t, err)
@@ -32,14 +32,14 @@ func Test_StaticProvider(t *testing.T) {
[feature_toggles]
featureOne = true
`)
p := provider(t, conf)
p := setup(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(``))
p := setup(t, []byte(``))
missingFeature, err := p.Client.BooleanValueDetails(ctx, "missingFeature", true, evalCtx)
assert.Error(t, err)
assert.True(t, missingFeature.Value)
@@ -47,7 +47,7 @@ featureOne = true
})
}
func provider(t *testing.T, conf []byte) *OpenFeatureService {
func setup(t *testing.T, conf []byte) *OpenFeatureService {
t.Helper()
cfg, err := setting.NewCfgFromBytes(conf)
require.NoError(t, err)
@@ -64,16 +64,12 @@ func Test_CompareStaticProviderWithFeatureManager(t *testing.T) {
_, err = sec.NewKey("ABCD", "true")
require.NoError(t, err)
p, err := ProvideOpenFeatureService(cfg)
// Use StaticFlagEvaluator instead of OpenFeatureService for static evaluation
staticEvaluator, err := ProvideStaticEvaluator(cfg)
require.NoError(t, err)
_, ok := p.provider.(*inMemoryBulkProvider)
if !ok {
t.Fatalf("expected inMemoryBulkProvider, got %T", p.provider)
}
ctx := openfeature.WithTransactionContext(context.Background(), openfeature.NewEvaluationContext("grafana", nil))
allFlags, err := p.EvalAllFlagsWithStaticProvider(ctx)
allFlags, err := staticEvaluator.EvalAllFlags(ctx)
require.NoError(t, err)
openFeatureEnabledFlags := map[string]bool{}
@@ -86,7 +82,7 @@ func Test_CompareStaticProviderWithFeatureManager(t *testing.T) {
mgr, err := ProvideManagerService(cfg)
require.NoError(t, err)
// compare enabled feature flags match between OpenFeature static provider and Feature Manager
// compare enabled feature flags match between StaticFlagEvaluator and Feature Manager
enabledFeatureManager := mgr.GetEnabled(ctx)
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
}