Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions core/pkg/evaluator/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ type flagdProperties struct {
type variantEvaluator func(context.Context, string, string, map[string]any) (
variant string, variants map[string]interface{}, reason string, metadata map[string]interface{}, error error)

// WithStrictValidation enables strict schema validation. When enabled, flag configurations
// that do not conform to the schema will be rejected with an error instead of accepted with a warning.
func WithStrictValidation() JSONEvaluatorOption {
return func(je *JSON) {
je.strictValidation = true
}
}

// Deprecated - this will be remove in the next release
func WithEvaluator(name string, evalFunc func(interface{}, interface{}) interface{}) JSONEvaluatorOption {
return func(_ *JSON) {
Expand All @@ -68,10 +76,11 @@ func WithEvaluator(name string, evalFunc func(interface{}, interface{}) interfac

// JSON evaluator
type JSON struct {
store store.IStore
Logger *logger.Logger
jsonEvalTracer trace.Tracer
jsonSchema *jsonschema.Schema
store store.IStore
Logger *logger.Logger
jsonEvalTracer trace.Tracer
jsonSchema *jsonschema.Schema
strictValidation bool
Resolver
}

Expand Down Expand Up @@ -467,6 +476,10 @@ func (je *JSON) configToFlagDefinition(config string, definition *Definition) er
return fmt.Errorf("failed to unmarshal JSON string: %v", err)
}
if err := je.jsonSchema.Validate(inst); err != nil {
if je.strictValidation {
return fmt.Errorf(
"flag definition does not conform to the schema; validation errors: %w", err)
}
je.Logger.Warn(fmt.Sprintf(
"flag definition does not conform to the schema; validation errors: %s", err),
)
Expand Down
51 changes: 51 additions & 0 deletions core/pkg/evaluator/json_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,57 @@ func TestSetState_Valid_NoError(t *testing.T) {
}
}

func TestSetState_StrictValidation_InvalidFlags_ReturnsError(t *testing.T) {
evaluator := flagdEvaluator.NewJSON(
logger.NewLogger(nil, false), store.NewFlags(), flagdEvaluator.WithStrictValidation(),
)

// set state with an invalid flag definition should return error in strict mode
err := evaluator.SetState(sync.DataSync{FlagData: InvalidFlags, Source: "testSource"})
require.Error(t, err)
assert.Contains(t, err.Error(), "flag definition does not conform to the schema")
}

func TestSetState_StrictValidation_ValidFlags_NoError(t *testing.T) {
evaluator := flagdEvaluator.NewJSON(
logger.NewLogger(nil, false), store.NewFlags(), flagdEvaluator.WithStrictValidation(),
)

// set state with a valid flag definition should succeed in strict mode
err := evaluator.SetState(sync.DataSync{FlagData: ValidFlags, Source: "testSource"})
require.NoError(t, err)
}

func TestSetState_StrictValidation_PreservesExistingState(t *testing.T) {
evaluator := flagdEvaluator.NewJSON(
logger.NewLogger(nil, false), store.NewFlags(), flagdEvaluator.WithStrictValidation(),
)

// first, load valid flags
err := evaluator.SetState(sync.DataSync{FlagData: ValidFlags, Source: "testSource"})
require.NoError(t, err)

// verify the flag is accessible
val := evaluator.ResolveAsAnyValue(context.Background(), "", ValidFlag, nil)
require.NoError(t, val.Error)

// now try to load invalid flags - should fail
err = evaluator.SetState(sync.DataSync{FlagData: InvalidFlags, Source: "testSource"})
require.Error(t, err)

// verify the original valid flag is still accessible (store was not modified)
val = evaluator.ResolveAsAnyValue(context.Background(), "", ValidFlag, nil)
require.NoError(t, val.Error)
}

func TestSetState_WithoutStrictValidation_InvalidFlags_NoError(t *testing.T) {
// without strict validation, invalid flags should still be accepted (backward compatible)
evaluator := flagdEvaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())

err := evaluator.SetState(sync.DataSync{FlagData: InvalidFlags, Source: "testSource"})
require.NoError(t, err)
}

func TestResolveAllValues(t *testing.T) {
evaluator := flagdEvaluator.NewJSON(logger.NewLogger(nil, false), store.NewFlags())
err := evaluator.SetState(sync.DataSync{FlagData: flagConfig, Source: "testSource"})
Expand Down
1 change: 1 addition & 0 deletions docs/reference/flagd-cli/flagd_start.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ flagd start [flags]
-d, --socket-path string Flagd unix socket path. With grpc the evaluations service will become available on this address. With http(s) the grpc-gateway proxy will use this address internally.
-s, --sources string JSON representation of an array of SourceConfig objects. Required fields: uri (string) and provider (string). Optional source-specific fields are also available, see https://flagd.dev/reference/sync-configuration/#source-configuration
--stream-deadline duration Set a server-side deadline for flagd sync and event streams (default 0, means no deadline).
--strict-validation Enables strict schema validation. Invalid initial configurations cause flagd to exit on startup; readiness is gated on every source producing a valid configuration. WARNING: a bad configuration delivered to multiple flagd instances will cause all of them to exit, potentially leading to a cascading failure.
-g, --sync-port int32 gRPC Sync port (default 8015)
-e, --sync-socket-path string Flagd sync service socket path. With grpc the sync service will be available on this address.
-f, --uri .yaml/.yml/.json Set a sync provider uri to read data from, this can be a filepath, URL (HTTP and gRPC), FeatureFlag custom resource, or GCS, Azure Blob or S3. When flag keys are duplicated across multiple providers the merge priority follows the index of the flag arguments, as such flags from the uri at index 0 take the lowest precedence, with duplicated keys being overwritten by those from the uri at index 1. Please note that if you are using filepath, flagd only supports files with .yaml/.yml/.json extension.
Expand Down
4 changes: 4 additions & 0 deletions flagd/cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const (
syncSocketPathFlagName = "sync-socket-path"
uriFlagName = "uri"
disableSyncMetadata = "disable-sync-metadata"
strictValidationFlagName = "strict-validation"
contextValueFlagName = "context-value"
headerToContextKeyFlagName = "context-from-header"
streamDeadlineFlagName = "stream-deadline"
Expand Down Expand Up @@ -94,6 +95,7 @@ func init() {
"header values to context values, where key is Header name, value is context key")
flags.Duration(streamDeadlineFlagName, 0, "Set a server-side deadline for flagd sync and event streams (default 0, means no deadline).")
flags.Bool(disableSyncMetadata, false, "Disables the getMetadata endpoint of the sync service. Defaults to false, but will default to true in later versions.")
flags.Bool(strictValidationFlagName, false, "Enables strict schema validation. Invalid initial configurations cause flagd to exit on startup; readiness is gated on every source producing a valid configuration. WARNING: a bad configuration delivered to multiple flagd instances will cause all of them to exit, potentially leading to a cascading failure.")
flags.Int64P(maxRequestBodyFlagName, "B", 1_000_000, "Maximum allowed request body size in bytes. Requests exceeding this are rejected with HTTP 413 (OFREP) or 429 (connect). Set to 0 to disable. WARNING: disabling this limit may allow memory exhaustion from oversized requests.")
flags.Int64P(maxRequestHeaderFlagName, "R", 1_000_000, "Maximum allowed request header size in bytes. Requests exceeding this are rejected with HTTP 431. Set to 0 to use Go's built-in default (1 MiB). WARNING: setting a very large or zero value may allow memory exhaustion from oversized headers.")

Expand Down Expand Up @@ -122,6 +124,7 @@ func bindFlags(flags *pflag.FlagSet) {
_ = viper.BindPFlag(headerToContextKeyFlagName, flags.Lookup(headerToContextKeyFlagName))
_ = viper.BindPFlag(streamDeadlineFlagName, flags.Lookup(streamDeadlineFlagName))
_ = viper.BindPFlag(disableSyncMetadata, flags.Lookup(disableSyncMetadata))
_ = viper.BindPFlag(strictValidationFlagName, flags.Lookup(strictValidationFlagName))
_ = viper.BindPFlag(maxRequestBodyFlagName, flags.Lookup(maxRequestBodyFlagName))
_ = viper.BindPFlag(maxRequestHeaderFlagName, flags.Lookup(maxRequestHeaderFlagName))
}
Expand Down Expand Up @@ -212,6 +215,7 @@ var startCmd = &cobra.Command{
HeaderToContextKeyMappings: headerToContextKeyMappings,
MaxRequestBodyBytes: maxRequestBodyBytes,
MaxRequestHeaderBytes: maxRequestHeaderBytes,
StrictValidation: viper.GetBool(strictValidationFlagName),
})
if err != nil {
rtLogger.Fatal(err.Error())
Expand Down
9 changes: 8 additions & 1 deletion flagd/pkg/runtime/from_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type Config struct {
HeaderToContextKeyMappings map[string]string
MaxRequestBodyBytes int64
MaxRequestHeaderBytes int64
StrictValidation bool
}

// FromConfig builds a runtime from startup configurations
Expand Down Expand Up @@ -94,7 +95,11 @@ func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime,
}

// derive evaluator
jsonEvaluator := evaluator.NewJSON(logger, store)
var evaluatorOpts []evaluator.JSONEvaluatorOption
if config.StrictValidation {
evaluatorOpts = append(evaluatorOpts, evaluator.WithStrictValidation())
}
jsonEvaluator := evaluator.NewJSON(logger, store, evaluatorOpts...)

// derive services

Expand Down Expand Up @@ -158,6 +163,8 @@ func FromConfig(logger *logger.Logger, version string, config Config) (*Runtime,
SyncService: flagSyncService,
OfrepService: ofrepService,
EvaluationService: connectService,
StrictValidation: config.StrictValidation,
ExpectedSources: sources,
ServiceConfig: service.Configuration{
Port: config.ServicePort,
ManagementPort: config.ManagementPort,
Expand Down
49 changes: 42 additions & 7 deletions flagd/pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ type Runtime struct {
EvaluationService service.IFlagEvaluationService
ServiceConfig service.Configuration
Syncs []sync.ISync
StrictValidation bool
ExpectedSources []string

mu msync.Mutex
mu msync.Mutex
validatedSources map[string]bool
}

//nolint:funlen
Expand All @@ -45,12 +48,17 @@ func (r *Runtime) Start() error {
defer cancel()
g, gCtx := errgroup.WithContext(ctx)
dataSync := make(chan sync.DataSync, len(r.Syncs))
if r.validatedSources == nil {
r.validatedSources = make(map[string]bool, len(r.Syncs))
}
// Initialize DataSync channel watcher
g.Go(func() error {
for {
select {
case data := <-dataSync:
r.updateAndEmit(data)
if err := r.updateAndEmit(data); err != nil {
return err
}
case <-gCtx.Done():
return nil
}
Expand Down Expand Up @@ -119,18 +127,45 @@ func (r *Runtime) isReady() bool {
return false
}
}
// in strict-validation mode, readiness additionally requires every
// configured source to have produced at least one valid configuration.
if r.StrictValidation {
r.mu.Lock()
defer r.mu.Unlock()
for _, src := range r.ExpectedSources {
if !r.validatedSources[src] {
return false
}
}
}
return true
}

// updateAndEmit helps to update state, notify changes and trigger sync updates
func (r *Runtime) updateAndEmit(payload sync.DataSync) {
// updateAndEmit helps to update state, notify changes and trigger sync updates.
// In strict-validation mode, an error on the *first* payload from a given source
// (i.e. before that source has ever produced a valid state) is returned so that
// startup can fail fast. Once a source has produced at least one valid payload it
// is recorded in validatedSources; subsequent errors from that source are logged
// and the previous valid state is preserved (current behavior).
func (r *Runtime) updateAndEmit(payload sync.DataSync) error {
r.mu.Lock()
defer r.mu.Unlock()

err := r.Evaluator.SetState(payload)
if err != nil {
if r.validatedSources == nil {
r.validatedSources = make(map[string]bool)
}

if err := r.Evaluator.SetState(payload); err != nil {
r.Logger.Error(fmt.Sprintf("error setting state: %v", err))
return
if r.StrictValidation && !r.validatedSources[payload.Source] {
return fmt.Errorf(
"strict validation: initial flag configuration from source %q is invalid: %w",
payload.Source, err,
)
}
return nil
}
r.validatedSources[payload.Source] = true
r.SyncService.Emit(payload.Source)
return nil
}
Loading
Loading