diff --git a/providers/flagd/flagd-testbed b/providers/flagd/flagd-testbed index fdce98780..b62f5dbe8 160000 --- a/providers/flagd/flagd-testbed +++ b/providers/flagd/flagd-testbed @@ -1 +1 @@ -Subproject commit fdce98780f5811bd4672fb7f2b56a6be05fc46d2 +Subproject commit b62f5dbe860ecf4f36ec757dfdc0b38f7b3dec6e diff --git a/providers/flagd/internal/mock/service_mock.go b/providers/flagd/internal/mock/service_mock.go index 2196c54cd..01f38cc88 100644 --- a/providers/flagd/internal/mock/service_mock.go +++ b/providers/flagd/internal/mock/service_mock.go @@ -148,3 +148,7 @@ func (mr *MockIServiceMockRecorder) Shutdown() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockIService)(nil).Shutdown)) } + +func (m *MockIService) ContextValues() map[string]any { + return nil +} \ No newline at end of file diff --git a/providers/flagd/pkg/configuration.go b/providers/flagd/pkg/configuration.go index e41ab4132..c40d4711b 100644 --- a/providers/flagd/pkg/configuration.go +++ b/providers/flagd/pkg/configuration.go @@ -3,6 +3,7 @@ package flagd import ( "errors" "fmt" + of "github.com/open-feature/go-sdk/openfeature" "os" "strconv" "strings" @@ -64,6 +65,7 @@ type ProviderConfiguration struct { CustomSyncProvider sync.ISync CustomSyncProviderUri string GrpcDialOptionsOverride []grpc.DialOption + ContextEnricher ContextEnricher log logr.Logger } @@ -77,6 +79,10 @@ func newDefaultConfiguration(log logr.Logger) *ProviderConfiguration { MaxCacheSize: defaultMaxCacheSize, Resolver: defaultResolver, Tls: defaultTLS, + ContextEnricher: func(contextValues map[string]any) *of.EvaluationContext { + evaluationContext := of.NewTargetlessEvaluationContext(contextValues) + return &evaluationContext + }, } p.updateFromEnvVar() @@ -397,3 +403,10 @@ func WithGrpcDialOptionsOverride(grpcDialOptionsOverride []grpc.DialOption) Prov p.GrpcDialOptionsOverride = grpcDialOptionsOverride } } + +// WithContextEnricher allows to add a custom context enricher (BeforeHook) +func WithContextEnricher(contextEnricher ContextEnricher) ProviderOption { + return func(p *ProviderConfiguration) { + p.ContextEnricher = contextEnricher + } +} diff --git a/providers/flagd/pkg/iservice.go b/providers/flagd/pkg/iservice.go index e25c49c1b..6e1d42333 100644 --- a/providers/flagd/pkg/iservice.go +++ b/providers/flagd/pkg/iservice.go @@ -21,4 +21,5 @@ type IService interface { ResolveObject(ctx context.Context, key string, defaultValue interface{}, evalCtx map[string]interface{}) of.InterfaceResolutionDetail EventChannel() <-chan of.Event + ContextValues() map[string]any } diff --git a/providers/flagd/pkg/provider.go b/providers/flagd/pkg/provider.go index 2b0084c32..455a3d7f8 100644 --- a/providers/flagd/pkg/provider.go +++ b/providers/flagd/pkg/provider.go @@ -22,8 +22,8 @@ type Provider struct { service IService status of.State mtx parallel.RWMutex - - eventStream chan of.Event + hooks []of.Hook + eventStream chan of.Event } func NewProvider(opts ...ProviderOption) (*Provider, error) { @@ -38,6 +38,7 @@ func NewProvider(opts ...ProviderOption) (*Provider, error) { eventStream: make(chan of.Event), providerConfiguration: providerConfiguration, status: of.NotReadyState, + hooks: []of.Hook{}, } cacheService := cache.NewCacheService( @@ -73,12 +74,18 @@ func NewProvider(opts ...ProviderOption) (*Provider, error) { CustomSyncProviderUri: provider.providerConfiguration.CustomSyncProviderUri, GrpcDialOptionsOverride: provider.providerConfiguration.GrpcDialOptionsOverride, }) + } else { service = process.NewInProcessService(process.Configuration{ OfflineFlagSource: provider.providerConfiguration.OfflineFlagSourcePath, }) } + if provider.providerConfiguration.Resolver == inProcess { + provider.hooks = append(provider.hooks, NewSyncContextHook(func() *of.EvaluationContext { + return provider.providerConfiguration.ContextEnricher(service.ContextValues()) + })) + } provider.service = service return provider, nil @@ -145,7 +152,7 @@ func (p *Provider) EventChannel() <-chan of.Event { // Hooks flagd provider does not have any hooks, returns empty slice func (p *Provider) Hooks() []of.Hook { - return []of.Hook{} + return p.hooks } // Metadata returns value of Metadata (name of current service, exposed to openfeature sdk) diff --git a/providers/flagd/pkg/provider_test.go b/providers/flagd/pkg/provider_test.go index b23cd9ce3..afab6dc06 100644 --- a/providers/flagd/pkg/provider_test.go +++ b/providers/flagd/pkg/provider_test.go @@ -22,6 +22,12 @@ func TestNewProvider(t *testing.T) { grpc.WithAuthority("test-authority"), } + enrichment := make(map[string]any) + enrichment["test"] = "test" + + expectedEnrichment := make(map[string]any) + expectedEnrichment["test"] = "test" + expectedEnrichment["test2"] = "test2" tests := []struct { name string expectedResolver ResolverType @@ -41,6 +47,7 @@ func TestNewProvider(t *testing.T) { expectCustomSyncProviderUri string expectOfflineFlagSourcePath string expectGrpcDialOptionsOverride []grpc.DialOption + expectContextEnrichment map[string]any options []ProviderOption }{ { @@ -269,6 +276,45 @@ func TestNewProvider(t *testing.T) { WithOfflineFilePath("offlineFilePath"), }, }, + { + name: "with ContextEnricher inprocess resolver", + expectedResolver: inProcess, + expectHost: defaultHost, + expectPort: defaultInProcessPort, + expectCacheType: defaultCache, + expectCacheSize: defaultMaxCacheSize, + expectMaxRetries: defaultMaxEventStreamRetries, + expectContextEnrichment: enrichment, + options: []ProviderOption{ + WithInProcessResolver(), + WithContextEnricher(func(m map[string]any) *of.EvaluationContext { + context := of.NewTargetlessEvaluationContext(m) + return &context + }), + }, + }, + { + name: "with ContextEnricher inprocess resolver adding values", + expectedResolver: inProcess, + expectHost: defaultHost, + expectPort: defaultInProcessPort, + expectCacheType: defaultCache, + expectCacheSize: defaultMaxCacheSize, + expectMaxRetries: defaultMaxEventStreamRetries, + expectContextEnrichment: expectedEnrichment, + options: []ProviderOption{ + WithInProcessResolver(), + WithContextEnricher(func(m map[string]any) *of.EvaluationContext { + newMap := make(map[string]any, len(m)+1) + for k, v := range m { + newMap[k] = v + } + newMap["test2"] = "test2" + context := of.NewTargetlessEvaluationContext(newMap) + return &context + }), + }, + }, } for _, test := range tests { @@ -369,6 +415,13 @@ func TestNewProvider(t *testing.T) { } } + if test.expectContextEnrichment != nil { + enriched := config.ContextEnricher(enrichment).Attributes() + if !reflect.DeepEqual(test.expectContextEnrichment, enriched) { + t.Errorf("incorrect context_enrichment attribute, expected %v, got %v", test.expectContextEnrichment, enriched) + } + } + // this line will fail linting if this provider is no longer compatible with the openfeature sdk var _ of.FeatureProvider = flagdProvider }) diff --git a/providers/flagd/pkg/service/in_process/service.go b/providers/flagd/pkg/service/in_process/service.go index 468b1c64b..468875f5f 100644 --- a/providers/flagd/pkg/service/in_process/service.go +++ b/providers/flagd/pkg/service/in_process/service.go @@ -3,7 +3,6 @@ package process import ( "context" "fmt" - "regexp" parallel "sync" @@ -33,6 +32,8 @@ type InProcess struct { sync sync.ISync syncEnd context.CancelFunc wg parallel.WaitGroup + contextValues map[string]any + mtx parallel.RWMutex } type Configuration struct { @@ -113,6 +114,12 @@ func (i *InProcess) Init() error { case data := <-syncChan: // re-syncs are ignored as we only support single flag sync source changes, _, err := i.evaluator.SetState(data) + if data.SyncContext != nil { + i.mtx.Lock() + i.contextValues = data.SyncContext.AsMap() + i.mtx.Unlock() + } + if err != nil { i.events <- of.Event{ ProviderName: "flagd", EventType: of.ProviderError, @@ -294,6 +301,22 @@ func (i *InProcess) appendMetadata(evalMetadata model.Metadata) { } } +func (i *InProcess) ContextValues() map[string]any { + i.mtx.RLock() + defer i.mtx.RUnlock() + + if i.contextValues == nil { + return nil + } + + // Return a copy to prevent mutation of internal state + contextValuesCopy := make(map[string]any, len(i.contextValues)) + for k, v := range i.contextValues { + contextValuesCopy[k] = v + } + return contextValuesCopy +} + // makeSyncProvider is a helper to create sync.ISync and return the underlying uri used by it to the caller func makeSyncProvider(cfg Configuration, log *logger.Logger) (sync.ISync, string) { if cfg.CustomSyncProvider != nil { diff --git a/providers/flagd/pkg/service/in_process/service_grpc_test.go b/providers/flagd/pkg/service/in_process/service_grpc_test.go index a1e7c1c1b..d934acb42 100644 --- a/providers/flagd/pkg/service/in_process/service_grpc_test.go +++ b/providers/flagd/pkg/service/in_process/service_grpc_test.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/open-feature/go-sdk/openfeature" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/structpb" "log" "net" "testing" @@ -38,11 +39,20 @@ func TestInProcessProviderEvaluation(t *testing.T) { t.Fatal(err) } + m := make(map[string]any) + + m["context"] = "set" + syncContext, err := structpb.NewStruct(m) + if err != nil { + t.Fatal(err) + } + bufServ := &bufferedServer{ listener: listen, mockResponses: []*v1.SyncFlagsResponse{ { FlagConfiguration: flagRsp, + SyncContext: syncContext, }, }, fetchAllFlagsResponse: nil, @@ -112,6 +122,10 @@ func TestInProcessProviderEvaluation(t *testing.T) { if scope != detail.FlagMetadata["scope"] { t.Fatalf("Wrong scope value. Expected %s, but got %s", scope, detail.FlagMetadata["scope"]) } + + if len(inProcessService.contextValues) == 0 { + t.Fatal("Expected context_values to be present, but got none") + } } // custom name resolver @@ -138,7 +152,7 @@ func TestInProcessProviderEvaluationEnvoy(t *testing.T) { } inProcessService := NewInProcessService(Configuration{ - TargetUri: "envoy://localhost:9211/foo.service", + TargetUri: "envoy://localhost:9211/foo.service", Selector: scope, TLSEnabled: false, }) @@ -201,7 +215,6 @@ func TestInProcessProviderEvaluationEnvoy(t *testing.T) { } } - // bufferedServer - a mock grpc service backed by buffered connection type bufferedServer struct { listener net.Listener diff --git a/providers/flagd/pkg/service/rpc/service.go b/providers/flagd/pkg/service/rpc/service.go index 8eab6a445..30daf7c9e 100644 --- a/providers/flagd/pkg/service/rpc/service.go +++ b/providers/flagd/pkg/service/rpc/service.go @@ -444,6 +444,10 @@ func handleError(err error) openfeature.ResolutionError { return openfeature.NewGeneralResolutionError(err.Error()) } +func (s *Service) ContextValues() map[string]any { + return nil +} + func (s *Service) EventChannel() <-chan of.Event { return s.events } diff --git a/providers/flagd/pkg/sync_context_hook.go b/providers/flagd/pkg/sync_context_hook.go new file mode 100644 index 000000000..531d9c0d1 --- /dev/null +++ b/providers/flagd/pkg/sync_context_hook.go @@ -0,0 +1,21 @@ +package flagd + +import ( + "context" + "github.com/open-feature/go-sdk/openfeature" +) + +type ContextEnricher func(map[string]any) *openfeature.EvaluationContext + +type SyncContextHook struct { + openfeature.UnimplementedHook + contextEnricher func() *openfeature.EvaluationContext +} + +func NewSyncContextHook(contextEnricher func() *openfeature.EvaluationContext) SyncContextHook { + return SyncContextHook{contextEnricher: contextEnricher} +} + +func (hook SyncContextHook) Before(ctx context.Context, hookContext openfeature.HookContext, hookHints openfeature.HookHints) (*openfeature.EvaluationContext, error) { + return hook.contextEnricher(), nil +} diff --git a/tests/flagd/go.mod b/tests/flagd/go.mod index dac9aec42..d5817d2d9 100644 --- a/tests/flagd/go.mod +++ b/tests/flagd/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.4 require ( github.com/cucumber/godog v0.15.1 + github.com/docker/go-connections v0.5.0 github.com/open-feature/go-sdk v1.11.0 github.com/open-feature/go-sdk-contrib/providers/flagd v0.3.0 github.com/testcontainers/testcontainers-go v0.38.0 @@ -70,7 +71,6 @@ require ( github.com/docker/docker v28.2.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect - github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-metrics v0.0.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.8.4 // indirect