From 752ed1ac2457e2e8a263895d964c87f8e4f834ad Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Fri, 5 Dec 2025 01:01:52 +0000 Subject: [PATCH 1/3] config: unify loading phases and system defaults Refactors the configuration loader to clarify the two-stage loading process and standardize default injection. This change: - Merges the loading lifecycle into a linear pipeline: Load Raw -> Instantiate -> Apply System Defaults -> Validate -> Build. - Introduces a `registerDefaultPlugin` helper to standardize how mandatory components (like MaxScorePicker and SingleProfileHandler) are injected when omitted by the user. - Improves error wrapping context throughout the loading process. --- pkg/epp/config/loader/configloader.go | 247 +++++++++++++++----------- pkg/epp/config/loader/defaults.go | 193 ++++++++++---------- pkg/epp/config/loader/validation.go | 59 +++--- 3 files changed, 279 insertions(+), 220 deletions(-) diff --git a/pkg/epp/config/loader/configloader.go b/pkg/epp/config/loader/configloader.go index 56f05dcfc..398cb914a 100644 --- a/pkg/epp/config/loader/configloader.go +++ b/pkg/epp/config/loader/configloader.go @@ -35,171 +35,206 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile" ) -var scheme = runtime.NewScheme() - -var registeredFeatureGates = sets.New[string]() // set of feature gates names, a name must be unique +var ( + scheme = runtime.NewScheme() + registeredFeatureGates = sets.New[string]() +) func init() { utilruntime.Must(configapi.Install(scheme)) } -// LoadConfigPhaseOne first phase of loading configuration from supplied text that was converted to []byte -func LoadConfigPhaseOne(configBytes []byte, logger logr.Logger) (*configapi.EndpointPickerConfig, map[string]bool, error) { - rawConfig, err := loadRawConfig(configBytes) +// RegisterFeatureGate registers a feature gate name for validation purposes. +func RegisterFeatureGate(gate string) { + registeredFeatureGates.Insert(gate) +} + +// LoadRawConfig parses the raw configuration bytes, applies initial defaults, and extracts feature gates. +// It does not instantiate plugins. +func LoadRawConfig(configBytes []byte, logger logr.Logger) (*configapi.EndpointPickerConfig, map[string]bool, error) { + // Decode JSON/YAML. + rawConfig, err := decodeRawConfig(configBytes) if err != nil { return nil, nil, err } + logger.V(1).Info("Loaded raw configuration", "config", rawConfig) - logger.Info("Loaded configuration", "config", rawConfig) + // Sanitize data. + applyStaticDefaults(rawConfig) - if err = validateFeatureGates(rawConfig.FeatureGates); err != nil { - return nil, nil, fmt.Errorf("failed to validate feature gates - %w", err) + // Early validation of Feature Gates. + // We validate gates early because they might dictate downstream loading logic. + if err := validateFeatureGates(rawConfig.FeatureGates); err != nil { + return nil, nil, fmt.Errorf("feature gate validation failed: %w", err) } - setDefaultsPhaseOne(rawConfig) - featureConfig := loadFeatureConfig(rawConfig.FeatureGates) - return rawConfig, featureConfig, nil } -// LoadConfigPhaseOne first phase of loading configuration from supplied text that was converted to []byte -func LoadConfigPhaseTwo(rawConfig *configapi.EndpointPickerConfig, handle plugins.Handle, logger logr.Logger) (*config.Config, error) { - var err error - // instantiate loaded plugins - if err = instantiatePlugins(rawConfig.Plugins, handle); err != nil { - return nil, fmt.Errorf("failed to instantiate plugins - %w", err) - } - - setDefaultsPhaseTwo(rawConfig, handle) +// InstantiateAndConfigure performs the heavy lifting of plugin instantiation, system architecture injection, and +// scheduler construction. +func InstantiateAndConfigure( + rawConfig *configapi.EndpointPickerConfig, + handle plugins.Handle, + logger logr.Logger, +) (*config.Config, error) { - logger.Info("Configuration with defaults set", "config", rawConfig) + // Instantiate user-configured plugins. + if err := instantiatePlugins(rawConfig.Plugins, handle); err != nil { + return nil, fmt.Errorf("plugin instantiation failed: %w", err) + } - if err = validateSchedulingProfiles(rawConfig); err != nil { - return nil, fmt.Errorf("failed to validate scheduling profiles - %w", err) + // Fill in the architectural gaps (inject required system plugins). + if err := applySystemDefaults(rawConfig, handle); err != nil { + return nil, fmt.Errorf("system default application failed: %w", err) } + logger.Info("Effective configuration loaded", "config", rawConfig) - config := &config.Config{} + // Deep validation checks relationships between now-finalized profiles and plugins. + if err := validateConfig(rawConfig); err != nil { + return nil, fmt.Errorf("configuration validation failed: %w", err) + } - config.SchedulerConfig, err = loadSchedulerConfig(rawConfig.SchedulingProfiles, handle) + // Build scheduler config. + schedulerConfig, err := buildSchedulerConfig(rawConfig.SchedulingProfiles, handle) if err != nil { - return nil, err + return nil, fmt.Errorf("scheduler config build failed: %w", err) } - config.SaturationDetectorConfig = loadSaturationDetectorConfig(rawConfig.SaturationDetector) - return config, nil + return &config.Config{ + SchedulerConfig: schedulerConfig, + SaturationDetectorConfig: buildSaturationConfig(rawConfig.SaturationDetector), + }, nil } -func loadRawConfig(configBytes []byte) (*configapi.EndpointPickerConfig, error) { - rawConfig := &configapi.EndpointPickerConfig{} - +func decodeRawConfig(configBytes []byte) (*configapi.EndpointPickerConfig, error) { + cfg := &configapi.EndpointPickerConfig{} codecs := serializer.NewCodecFactory(scheme, serializer.EnableStrict) - err := runtime.DecodeInto(codecs.UniversalDecoder(), configBytes, rawConfig) - if err != nil { - return nil, fmt.Errorf("the configuration is invalid - %w", err) + if err := runtime.DecodeInto(codecs.UniversalDecoder(), configBytes, cfg); err != nil { + return nil, fmt.Errorf("failed to decode configuration JSON/YAML: %w", err) } - return rawConfig, nil + return cfg, nil } -func loadSchedulerConfig(configProfiles []configapi.SchedulingProfile, handle plugins.Handle) (*scheduling.SchedulerConfig, error) { - profiles := map[string]*framework.SchedulerProfile{} - for _, namedProfile := range configProfiles { - profile := framework.NewSchedulerProfile() - for _, plugin := range namedProfile.Plugins { - referencedPlugin := handle.Plugin(plugin.PluginRef) - if scorer, ok := referencedPlugin.(framework.Scorer); ok { - referencedPlugin = framework.NewWeightedScorer(scorer, *plugin.Weight) +func instantiatePlugins(configuredPlugins []configapi.PluginSpec, handle plugins.Handle) error { + pluginNames := sets.New[string]() + for _, spec := range configuredPlugins { + if spec.Type == "" { + return fmt.Errorf("plugin '%s' is missing a type", spec.Name) + } + if pluginNames.Has(spec.Name) { + return fmt.Errorf("duplicate plugin name '%s'", spec.Name) + } + pluginNames.Insert(spec.Name) + + factory, ok := plugins.Registry[spec.Type] + if !ok { + return fmt.Errorf("plugin type '%s' is not registered", spec.Type) + } + + plugin, err := factory(spec.Name, spec.Parameters, handle) + if err != nil { + return fmt.Errorf("failed to create plugin '%s' (type: %s): %w", spec.Name, spec.Type, err) + } + + handle.AddPlugin(spec.Name, plugin) + } + return nil +} + +func buildSchedulerConfig( + configProfiles []configapi.SchedulingProfile, + handle plugins.Handle, +) (*scheduling.SchedulerConfig, error) { + + profiles := make(map[string]*framework.SchedulerProfile) + + // Build profiles. + for _, cfgProfile := range configProfiles { + fwProfile := framework.NewSchedulerProfile() + + for _, pluginRef := range cfgProfile.Plugins { + plugin := handle.Plugin(pluginRef.PluginRef) + if plugin == nil { // Should be caught by validation, but defensive check. + return nil, fmt.Errorf( + "plugin '%s' referenced in profile '%s' not found in handle", + pluginRef.PluginRef, cfgProfile.Name) } - if err := profile.AddPlugins(referencedPlugin); err != nil { - return nil, fmt.Errorf("failed to load scheduler config - %w", err) + + // Wrap Scorers with weights. + if scorer, ok := plugin.(framework.Scorer); ok { + weight := DefaultScorerWeight + if pluginRef.Weight != nil { + weight = *pluginRef.Weight + } + plugin = framework.NewWeightedScorer(scorer, weight) + } + + if err := fwProfile.AddPlugins(plugin); err != nil { + return nil, fmt.Errorf("failed to add plugin '%s' to profile '%s': %w", pluginRef.PluginRef, cfgProfile.Name, err) } } - profiles[namedProfile.Name] = profile + profiles[cfgProfile.Name] = fwProfile } + // Find Profile Handler (singleton check). var profileHandler framework.ProfileHandler - for pluginName, plugin := range handle.GetAllPluginsWithNames() { - if theProfileHandler, ok := plugin.(framework.ProfileHandler); ok { + for name, plugin := range handle.GetAllPluginsWithNames() { + if ph, ok := plugin.(framework.ProfileHandler); ok { if profileHandler != nil { - return nil, fmt.Errorf("only one profile handler is allowed. Both %s and %s are profile handlers", profileHandler.TypedName().Name, pluginName) + return nil, fmt.Errorf("multiple profile handlers found ('%s', '%s'); only one is allowed", + profileHandler.TypedName().Name, name) } - profileHandler = theProfileHandler + profileHandler = ph } } + if profileHandler == nil { - return nil, errors.New("no profile handler was specified") + return nil, errors.New("no profile handler configured") } + // Validate SingleProfileHandler usage. if profileHandler.TypedName().Type == profile.SingleProfileHandlerType && len(profiles) > 1 { - return nil, errors.New("single profile handler is intended to be used with a single profile, but multiple profiles were specified") + return nil, errors.New("SingleProfileHandler cannot support multiple scheduling profiles") } return scheduling.NewSchedulerConfig(profileHandler, profiles), nil } -func loadFeatureConfig(featureGates configapi.FeatureGates) map[string]bool { - featureConfig := map[string]bool{} - +func loadFeatureConfig(gates configapi.FeatureGates) map[string]bool { + // Initialize with all false. + config := make(map[string]bool, len(registeredFeatureGates)) for gate := range registeredFeatureGates { - featureConfig[gate] = false + config[gate] = false } - - for _, gate := range featureGates { - featureConfig[gate] = true + // Apply overrides. + for _, gate := range gates { + config[gate] = true } - - return featureConfig + return config } -func loadSaturationDetectorConfig(sd *configapi.SaturationDetector) *saturationdetector.Config { - sdConfig := saturationdetector.Config{} - - sdConfig.QueueDepthThreshold = sd.QueueDepthThreshold - if sdConfig.QueueDepthThreshold <= 0 { - sdConfig.QueueDepthThreshold = saturationdetector.DefaultQueueDepthThreshold - } - sdConfig.KVCacheUtilThreshold = sd.KVCacheUtilThreshold - if sdConfig.KVCacheUtilThreshold <= 0.0 || sdConfig.KVCacheUtilThreshold >= 1.0 { - sdConfig.KVCacheUtilThreshold = saturationdetector.DefaultKVCacheUtilThreshold +func buildSaturationConfig(apiConfig *configapi.SaturationDetector) *saturationdetector.Config { + // 1. Initialize with Defaults + cfg := &saturationdetector.Config{ + QueueDepthThreshold: saturationdetector.DefaultQueueDepthThreshold, + KVCacheUtilThreshold: saturationdetector.DefaultKVCacheUtilThreshold, + MetricsStalenessThreshold: saturationdetector.DefaultMetricsStalenessThreshold, } - sdConfig.MetricsStalenessThreshold = sd.MetricsStalenessThreshold.Duration - if sdConfig.MetricsStalenessThreshold <= 0.0 { - sdConfig.MetricsStalenessThreshold = saturationdetector.DefaultMetricsStalenessThreshold - } - - return &sdConfig -} -func instantiatePlugins(configuredPlugins []configapi.PluginSpec, handle plugins.Handle) error { - pluginNames := sets.New[string]() // set of plugin names, a name must be unique - - for _, pluginConfig := range configuredPlugins { - if pluginConfig.Type == "" { - return fmt.Errorf("plugin definition for '%s' is missing a type", pluginConfig.Name) + // 2. Apply Overrides (if valid) + if apiConfig != nil { + if apiConfig.QueueDepthThreshold > 0 { + cfg.QueueDepthThreshold = apiConfig.QueueDepthThreshold } - - if pluginNames.Has(pluginConfig.Name) { - return fmt.Errorf("plugin name '%s' used more than once", pluginConfig.Name) + if apiConfig.KVCacheUtilThreshold > 0.0 && apiConfig.KVCacheUtilThreshold < 1.0 { + cfg.KVCacheUtilThreshold = apiConfig.KVCacheUtilThreshold } - pluginNames.Insert(pluginConfig.Name) - - factory, ok := plugins.Registry[pluginConfig.Type] - if !ok { - return fmt.Errorf("plugin type '%s' is not found in registry", pluginConfig.Type) - } - - plugin, err := factory(pluginConfig.Name, pluginConfig.Parameters, handle) - if err != nil { - return fmt.Errorf("failed to instantiate the plugin type '%s' - %w", pluginConfig.Type, err) + if apiConfig.MetricsStalenessThreshold.Duration > 0 { + cfg.MetricsStalenessThreshold = apiConfig.MetricsStalenessThreshold.Duration } - - handle.AddPlugin(pluginConfig.Name, plugin) } - return nil -} - -// RegisterFeatureGate registers feature gate keys for validation -func RegisterFeatureGate(gate string) { - registeredFeatureGates.Insert(gate) + return cfg } diff --git a/pkg/epp/config/loader/defaults.go b/pkg/epp/config/loader/defaults.go index 17075dd05..1b8658d44 100644 --- a/pkg/epp/config/loader/defaults.go +++ b/pkg/epp/config/loader/defaults.go @@ -17,141 +17,156 @@ limitations under the License. package loader import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "fmt" + configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/saturationdetector" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile" ) -const ( - // DefaultScorerWeight is the weight used for scorers referenced in the - // configuration without explicit weights. - DefaultScorerWeight = 1 -) - -// The code below sets the defaults in the configuration. It is done in two parts: -// 1) Before the plugins are instantiated, for those defaults that can be set -// without knowing the concrete type of plugins -// 2) After the plugins are instantiated, for things that one needs to know the -// concrete type of the plugins +// DefaultScorerWeight is the weight used for scorers referenced in the configuration without explicit weights. +const DefaultScorerWeight = 1 var defaultScorerWeight = DefaultScorerWeight -// setDefaultsPhaseOne Performs the first phase of setting configuration defaults. -// In particuylar it: -// 1. Sets the name of plugins, for which one wasn't specified -// 2. Sets defaults for the feature gates -// 3. Sets defaults for the SaturationDetector configuration -func setDefaultsPhaseOne(cfg *configapi.EndpointPickerConfig) { - // If no name was given for the plugin, use it's type as the name +// applyStaticDefaults standardizes the configuration object before plugin instantiation. +// It handles simple structural defaults that do not require knowledge of the plugin registry. +func applyStaticDefaults(cfg *configapi.EndpointPickerConfig) { + // Infer plugin names. If a plugin has a Type but no Name, the Type becomes the Name. for idx, pluginConfig := range cfg.Plugins { if pluginConfig.Name == "" { cfg.Plugins[idx].Name = pluginConfig.Type } } - // If no feature gates were specified, provide a default FeatureGates struct + // Initialize feature gates. if cfg.FeatureGates == nil { cfg.FeatureGates = configapi.FeatureGates{} } - - // If the SaturationDetector configuration wasn't specified setup a default one - if cfg.SaturationDetector == nil { - cfg.SaturationDetector = &configapi.SaturationDetector{} - } - if cfg.SaturationDetector.QueueDepthThreshold == 0 { - cfg.SaturationDetector.QueueDepthThreshold = saturationdetector.DefaultQueueDepthThreshold - } - if cfg.SaturationDetector.KVCacheUtilThreshold == 0.0 { - cfg.SaturationDetector.KVCacheUtilThreshold = saturationdetector.DefaultKVCacheUtilThreshold - } - if cfg.SaturationDetector.MetricsStalenessThreshold.Duration == 0.0 { - cfg.SaturationDetector.MetricsStalenessThreshold = - metav1.Duration{Duration: saturationdetector.DefaultMetricsStalenessThreshold} - } } -// setDefaultsPhaseTwo Performs the second phase of setting configuration defaults. -// In particular it: -// 1. Adds a default SchedulingProfile if one wasn't specified. -// 2. Adds an instance of the SingleProfileHandler, if no profile handler was -// specified and the configuration has only one SchedulingProfile -// 3. Sets a default weight for all scorers without a weight -// 4. Adds a picker (MaxScorePicker) to all SchedulingProfiles that don't have a picker -func setDefaultsPhaseTwo(cfg *configapi.EndpointPickerConfig, handle plugins.Handle) { +// applySystemDefaults injects required architectural components that were omitted from the config. +// It inspects the instantiated plugins (via the handle) and ensures the system graph is complete. +func applySystemDefaults(cfg *configapi.EndpointPickerConfig, handle plugins.Handle) error { allPlugins := handle.GetAllPluginsWithNames() - // If No SchedulerProfiles were specified in the confguration, - // create one named default with references to all of the scheduling - // plugins mentioned in the Plugins section of the configuration. - if len(cfg.SchedulingProfiles) == 0 { - cfg.SchedulingProfiles = make([]configapi.SchedulingProfile, 1) + // Ensure the scheduling layer has profiles, pickers, and handlers. + if err := ensureSchedulingArchitecture(cfg, handle, allPlugins); err != nil { + return fmt.Errorf("failed to apply scheduling system defaults: %w", err) + } + + return nil +} - thePlugins := []configapi.SchedulingPlugin{} - for pluginName, plugin := range allPlugins { - switch plugin.(type) { +// ensureSchedulingArchitecture guarantees that a valid scheduling profile exists and that all profiles have valid +// Pickers and Handlers. +func ensureSchedulingArchitecture( + cfg *configapi.EndpointPickerConfig, + handle plugins.Handle, + allPlugins map[string]plugins.Plugin, +) error { + // Ensure at least one Scheduling Profile exists. + if len(cfg.SchedulingProfiles) == 0 { + defaultProfile := configapi.SchedulingProfile{Name: "default"} + // Auto-populate the default profile with all Filter, Scorer, and Picker plugins found. + for name, p := range allPlugins { + switch p.(type) { case framework.Filter, framework.Scorer, framework.Picker: - thePlugins = append(thePlugins, configapi.SchedulingPlugin{PluginRef: pluginName}) + defaultProfile.Plugins = append(defaultProfile.Plugins, configapi.SchedulingPlugin{PluginRef: name}) } } - - cfg.SchedulingProfiles[0] = configapi.SchedulingProfile{ - Name: "default", - Plugins: thePlugins, - } + cfg.SchedulingProfiles = []configapi.SchedulingProfile{defaultProfile} } - // Add an instance of the SingleProfileHandler, if no profile handler was - // specified and the configuration has only one SchedulingProfile + // Ensure profile handler. + // If there is only 1 profile and no handler is explicitly configured, use the SingleProfileHandler. if len(cfg.SchedulingProfiles) == 1 { - profileHandlerFound := false - for _, plugin := range allPlugins { - if _, ok := plugin.(framework.ProfileHandler); ok { - profileHandlerFound = true + hasHandler := false + for _, p := range allPlugins { + if _, ok := p.(framework.ProfileHandler); ok { + hasHandler = true break } } - if !profileHandlerFound { - handle.AddPlugin(profile.SingleProfileHandlerType, profile.NewSingleProfileHandler()) - cfg.Plugins = append(cfg.Plugins, - configapi.PluginSpec{Name: profile.SingleProfileHandlerType, - Type: profile.SingleProfileHandlerType, - }) + if !hasHandler { + if err := registerDefaultPlugin(cfg, handle, profile.SingleProfileHandlerType, profile.SingleProfileHandlerType); err != nil { + return err + } } } - var maxScorePicker string - for pluginName, plugin := range allPlugins { - if _, ok := plugin.(framework.Picker); ok { - maxScorePicker = pluginName + // Ensure Picker(s) and Scorer weights. + // Find or Create a default MaxScorePicker to reuse across profiles. + var maxScorePickerName string + for name, p := range allPlugins { + if _, ok := p.(framework.Picker); ok { + maxScorePickerName = name break } } - if maxScorePicker == "" { - handle.AddPlugin(picker.MaxScorePickerType, picker.NewMaxScorePicker(picker.DefaultMaxNumOfEndpoints)) - maxScorePicker = picker.MaxScorePickerType - cfg.Plugins = append(cfg.Plugins, configapi.PluginSpec{Name: maxScorePicker, Type: maxScorePicker}) + // If no Picker exists anywhere, create one. + if maxScorePickerName == "" { + if err := registerDefaultPlugin(cfg, handle, picker.MaxScorePickerType, picker.MaxScorePickerType); err != nil { + return err + } + maxScorePickerName = picker.MaxScorePickerType } - for idx, theProfile := range cfg.SchedulingProfiles { + // Update profiles. + for i, prof := range cfg.SchedulingProfiles { hasPicker := false - for pluginIdx, plugin := range theProfile.Plugins { - referencedPlugin := handle.Plugin(plugin.PluginRef) - if _, ok := referencedPlugin.(framework.Scorer); ok && plugin.Weight == nil { - theProfile.Plugins[pluginIdx].Weight = &defaultScorerWeight - cfg.SchedulingProfiles[idx] = theProfile - } else if _, ok := referencedPlugin.(framework.Picker); ok { + for j, pluginRef := range prof.Plugins { + p := handle.Plugin(pluginRef.PluginRef) + + // Default Scorer weight. + if _, ok := p.(framework.Scorer); ok && pluginRef.Weight == nil { + cfg.SchedulingProfiles[i].Plugins[j].Weight = &defaultScorerWeight + } + + // Check for Picker. + if _, ok := p.(framework.Picker); ok { hasPicker = true - break } } + + // Inject default Picker if missing. if !hasPicker { - theProfile.Plugins = - append(theProfile.Plugins, configapi.SchedulingPlugin{PluginRef: maxScorePicker}) - cfg.SchedulingProfiles[idx] = theProfile + cfg.SchedulingProfiles[i].Plugins = append( + cfg.SchedulingProfiles[i].Plugins, + configapi.SchedulingPlugin{PluginRef: maxScorePickerName}, + ) } } + + return nil +} + +// registerDefaultPlugin instantiates a plugin with empty configuration (defaults) and adds it to both the handle and +// the config spec. +func registerDefaultPlugin( + cfg *configapi.EndpointPickerConfig, + handle plugins.Handle, + name string, + pluginType string, +) error { + factory, ok := plugins.Registry[pluginType] + if !ok { + return fmt.Errorf("plugin type '%s' not found in registry", pluginType) + } + + // Instantiate with nil config (factory must handle defaults). + plugin, err := factory(name, nil, handle) + if err != nil { + return fmt.Errorf("failed to instantiate default plugin '%s': %w", name, err) + } + + handle.AddPlugin(name, plugin) + cfg.Plugins = append(cfg.Plugins, configapi.PluginSpec{ + Name: name, + Type: pluginType, + }) + + return nil } diff --git a/pkg/epp/config/loader/validation.go b/pkg/epp/config/loader/validation.go index 7eed7ef4a..8df756d5a 100644 --- a/pkg/epp/config/loader/validation.go +++ b/pkg/epp/config/loader/validation.go @@ -17,53 +17,62 @@ limitations under the License. package loader import ( - "errors" "fmt" "k8s.io/apimachinery/pkg/util/sets" configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" ) -func validateSchedulingProfiles(config *configapi.EndpointPickerConfig) error { - profileNames := sets.New[string]() - for _, profile := range config.SchedulingProfiles { +// validateConfig performs a deep validation of the configuration integrity. +// It checks relationships between profiles, plugins, and feature gates. +func validateConfig(cfg *configapi.EndpointPickerConfig) error { + if err := validateFeatureGates(cfg.FeatureGates); err != nil { + return fmt.Errorf("feature gate validation failed: %w", err) + } + if err := validateSchedulingProfiles(cfg); err != nil { + return fmt.Errorf("scheduling profile validation failed: %w", err) + } + return nil +} + +func validateSchedulingProfiles(cfg *configapi.EndpointPickerConfig) error { + definedPlugins := sets.New[string]() + for _, p := range cfg.Plugins { + definedPlugins.Insert(p.Name) + } + seenProfileNames := sets.New[string]() + + for i, profile := range cfg.SchedulingProfiles { if profile.Name == "" { - return errors.New("SchedulingProfile must have a name") + return fmt.Errorf("schedulingProfiles[%d] is missing a name", i) } - - if profileNames.Has(profile.Name) { - return fmt.Errorf("the name '%s' has been specified for more than one SchedulingProfile", profile.Name) + if seenProfileNames.Has(profile.Name) { + return fmt.Errorf("schedulingProfiles[%d] has duplicate name '%s'", i, profile.Name) } - profileNames.Insert(profile.Name) + seenProfileNames.Insert(profile.Name) - for _, plugin := range profile.Plugins { - if len(plugin.PluginRef) == 0 { - return fmt.Errorf("SchedulingProfile '%s' plugins must have a plugin reference", profile.Name) + for j, pluginRef := range profile.Plugins { + if pluginRef.PluginRef == "" { + return fmt.Errorf("schedulingProfiles[%s].plugins[%d] is missing a 'pluginRef'", profile.Name, j) } - notFound := true - for _, pluginConfig := range config.Plugins { - if plugin.PluginRef == pluginConfig.Name { - notFound = false - break - } - } - if notFound { - return errors.New(plugin.PluginRef + " is a reference to an undefined Plugin") + if !definedPlugins.Has(pluginRef.PluginRef) { + return fmt.Errorf("schedulingProfiles[%s] references undefined plugin '%s'", + profile.Name, pluginRef.PluginRef) } } } return nil } -func validateFeatureGates(fg configapi.FeatureGates) error { - if fg == nil { +func validateFeatureGates(gates configapi.FeatureGates) error { + if gates == nil { return nil } - for _, gate := range fg { + for _, gate := range gates { if !registeredFeatureGates.Has(gate) { - return errors.New(gate + " is an unregistered Feature Gate") + return fmt.Errorf("feature gate '%s' is unknown or unregistered", gate) } } From 446318b1f18044558f8608773a70419db28fa013 Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Fri, 5 Dec 2025 01:03:27 +0000 Subject: [PATCH 2/3] test: overhaul config loader test suite Rewrites `configloader_test.go` to use table-driven tests and improves overall test hygiene. Key improvements: - Separates tests for "Raw Loading" (Phase 1) and "Instantiation" (Phase 2) to mirror the new production architecture. - Adds comprehensive test data constants in `testdata.go` to prevent drift between YAML and Go struct definitions. - Adds deep validation callbacks to verify post-instantiation state, ensuring defaults are injected correctly. --- pkg/epp/config/loader/configloader_test.go | 1133 +++++--------------- pkg/epp/config/loader/testdata_test.go | 335 ++++++ 2 files changed, 606 insertions(+), 862 deletions(-) create mode 100644 pkg/epp/config/loader/testdata_test.go diff --git a/pkg/epp/config/loader/configloader_test.go b/pkg/epp/config/loader/configloader_test.go index a23f22316..d81a4a1ef 100644 --- a/pkg/epp/config/loader/configloader_test.go +++ b/pkg/epp/config/loader/configloader_test.go @@ -19,21 +19,20 @@ package loader import ( "context" "encoding/json" - "os" "testing" "time" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/config" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datalayer" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/saturationdetector" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/multi/prefix" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" @@ -41,1003 +40,413 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/test/utils" ) +// Define constants for test plugins. +// Constants must match those used in testdata_test.go. const ( - testProfileHandlerType = "test-profile-handler" - test1Type = "test-one" - test2Type = "test-two" - testPickerType = "test-picker" + testPluginType = "test-plugin" + testPickerType = "test-picker" + testScorerType = "test-scorer" + testProfileHandler = "test-profile-handler" ) -type testStruct struct { - name string - configText string - configFile string - want *configapi.EndpointPickerConfig - wantErr bool -} +// --- Test: Phase 1 (Raw Loading & Static Defaults) --- func TestLoadRawConfiguration(t *testing.T) { - registerTestPlugins() + t.Parallel() - goodConfig := &configapi.EndpointPickerConfig{ - TypeMeta: metav1.TypeMeta{ - Kind: "EndpointPickerConfig", - APIVersion: "inference.networking.x-k8s.io/v1alpha1", - }, - Plugins: []configapi.PluginSpec{ - { - Name: "test1", - Type: test1Type, - Parameters: json.RawMessage("{\"threshold\":10}"), - }, - { - Name: "profileHandler", - Type: "test-profile-handler", - }, - { - Type: test2Type, - Parameters: json.RawMessage("{\"blockSize\":32}"), - }, - { - Name: "testPicker", - Type: testPickerType, - }, - }, - SchedulingProfiles: []configapi.SchedulingProfile{ - { - Name: "default", - Plugins: []configapi.SchedulingPlugin{ - { - PluginRef: "test1", - }, - { - PluginRef: "test-two", - Weight: ptr.To(50), - }, - { - PluginRef: "testPicker", - }, - }, - }, - }, - FeatureGates: configapi.FeatureGates{datalayer.FeatureGate}, - SaturationDetector: &configapi.SaturationDetector{ - MetricsStalenessThreshold: metav1.Duration{Duration: 150 * time.Millisecond}, - }, - } - - goodConfigNoProfiles := &configapi.EndpointPickerConfig{ - TypeMeta: metav1.TypeMeta{ - Kind: "EndpointPickerConfig", - APIVersion: "inference.networking.x-k8s.io/v1alpha1", - }, - Plugins: []configapi.PluginSpec{ - { - Name: "test1", - Type: test1Type, - Parameters: json.RawMessage("{\"threshold\":10}"), - }, - }, - } + // Register known feature gates for validation. + RegisterFeatureGate(datalayer.FeatureGate) - tests := []testStruct{ + tests := []struct { + name string + configText string + want *configapi.EndpointPickerConfig + wantErr bool + }{ { - name: "success", + name: "Success - Full Configuration", configText: successConfigText, - configFile: "", - want: goodConfig, - wantErr: false, - }, - { - name: "successNoProfiles", - configText: successNoProfilesText, - configFile: "", - want: goodConfigNoProfiles, - wantErr: false, - }, - { - name: "errorBadYaml", - configText: errorBadYamlText, - configFile: "", - wantErr: true, - }, - { - name: "successFromFile", - configText: "", - configFile: "../../../../test/testdata/configloader_1_test.yaml", - want: goodConfig, - wantErr: false, - }, - } - - for _, test := range tests { - configBytes := []byte(test.configText) - if test.configFile != "" { - configBytes, _ = os.ReadFile(test.configFile) - } - - got, err := loadRawConfig(configBytes) - checker(t, "loadRawConfig", test, got, err) - } -} - -func TestLoadRawConfigurationWithDefaults(t *testing.T) { - registerTestPlugins() - - goodConfig := &configapi.EndpointPickerConfig{ - TypeMeta: metav1.TypeMeta{ - Kind: "EndpointPickerConfig", - APIVersion: "inference.networking.x-k8s.io/v1alpha1", - }, - Plugins: []configapi.PluginSpec{ - { - Name: "test1", - Type: test1Type, - Parameters: json.RawMessage("{\"threshold\":10}"), - }, - { - Name: "profileHandler", - Type: "test-profile-handler", - }, - { - Name: test2Type, - Type: test2Type, - Parameters: json.RawMessage("{\"blockSize\":32}"), - }, - { - Name: "testPicker", - Type: testPickerType, - }, - }, - SchedulingProfiles: []configapi.SchedulingProfile{ - { - Name: "default", - Plugins: []configapi.SchedulingPlugin{ - { - PluginRef: "test1", - }, - { - PluginRef: "test-two", - Weight: ptr.To(50), - }, - { - PluginRef: "testPicker", - }, + want: &configapi.EndpointPickerConfig{ + TypeMeta: metav1.TypeMeta{ + Kind: "EndpointPickerConfig", + APIVersion: "inference.networking.x-k8s.io/v1alpha1", }, - }, - }, - FeatureGates: configapi.FeatureGates{datalayer.FeatureGate}, - SaturationDetector: &configapi.SaturationDetector{ - QueueDepthThreshold: saturationdetector.DefaultQueueDepthThreshold, - KVCacheUtilThreshold: saturationdetector.DefaultKVCacheUtilThreshold, - MetricsStalenessThreshold: metav1.Duration{Duration: 150 * time.Millisecond}, - }, - } - - goodConfigNoProfiles := &configapi.EndpointPickerConfig{ - TypeMeta: metav1.TypeMeta{ - Kind: "EndpointPickerConfig", - APIVersion: "inference.networking.x-k8s.io/v1alpha1", - }, - Plugins: []configapi.PluginSpec{ - { - Name: "test1", - Type: test1Type, - Parameters: json.RawMessage("{\"threshold\":10}"), - }, - { - Name: profile.SingleProfileHandlerType, - Type: profile.SingleProfileHandlerType, - }, - { - Name: picker.MaxScorePickerType, - Type: picker.MaxScorePickerType, - }, - }, - SchedulingProfiles: []configapi.SchedulingProfile{ - { - Name: "default", - Plugins: []configapi.SchedulingPlugin{ - { - PluginRef: "test1", - }, + Plugins: []configapi.PluginSpec{ + {Name: "test1", Type: testPluginType, Parameters: json.RawMessage(`{"threshold":10}`)}, + {Name: "profileHandler", Type: testProfileHandler}, + {Name: testScorerType, Type: testScorerType, Parameters: json.RawMessage(`{"blockSize":32}`)}, + {Name: "testPicker", Type: testPickerType}, + }, + SchedulingProfiles: []configapi.SchedulingProfile{ { - PluginRef: "max-score-picker", + Name: "default", + Plugins: []configapi.SchedulingPlugin{ + {PluginRef: "test1"}, + {PluginRef: testScorerType, Weight: ptr.To(50)}, + {PluginRef: "testPicker"}, + }, }, }, + FeatureGates: configapi.FeatureGates{datalayer.FeatureGate}, + SaturationDetector: &configapi.SaturationDetector{ + QueueDepthThreshold: 10, + KVCacheUtilThreshold: 0.8, + MetricsStalenessThreshold: metav1.Duration{Duration: 100 * time.Millisecond}, + }, }, - }, - FeatureGates: configapi.FeatureGates{}, - SaturationDetector: &configapi.SaturationDetector{ - QueueDepthThreshold: saturationdetector.DefaultQueueDepthThreshold, - KVCacheUtilThreshold: saturationdetector.DefaultKVCacheUtilThreshold, - MetricsStalenessThreshold: metav1.Duration{Duration: saturationdetector.DefaultMetricsStalenessThreshold}, - }, - } - - tests := []testStruct{ - { - name: "success", - configText: successConfigText, - configFile: "", - want: goodConfig, - wantErr: false, + wantErr: false, }, { - name: "successNoProfiles", + name: "Success - No Profiles", configText: successNoProfilesText, - configFile: "", - want: goodConfigNoProfiles, - wantErr: false, - }, - { - name: "errorBadPluginReferenceText", - configText: errorBadPluginReferenceText, - configFile: "", - wantErr: true, - }, - { - name: "errorBadPluginReferencePluginText", - configText: errorBadPluginReferencePluginText, - configFile: "", - wantErr: true, - }, - { - name: "errorNoProfileName", - configText: errorNoProfileNameText, - configFile: "", - wantErr: true, - }, - { - name: "errorBadProfilePlugin", - configText: errorBadProfilePluginText, - configFile: "", - wantErr: true, - }, - { - name: "errorBadProfilePluginRef", - configText: errorBadProfilePluginRefText, - configFile: "", - wantErr: true, + want: &configapi.EndpointPickerConfig{ + TypeMeta: metav1.TypeMeta{ + Kind: "EndpointPickerConfig", + APIVersion: "inference.networking.x-k8s.io/v1alpha1", + }, + Plugins: []configapi.PluginSpec{ + {Name: "test1", Type: testPluginType, Parameters: json.RawMessage(`{"threshold":10}`)}, + }, + FeatureGates: configapi.FeatureGates{}, + }, + wantErr: false, }, { - name: "errorDuplicatePlugin", - configText: errorDuplicatePluginText, - configFile: "", + name: "Error - Invalid YAML", + configText: errorBadYamlText, wantErr: true, }, { - name: "errorDuplicateProfile", - configText: errorDuplicateProfileText, - configFile: "", + name: "Error - Unknown Feature Gate", + configText: errorUnknownFeatureGateText, wantErr: true, }, - { - name: "successFromFile", - configText: "", - configFile: "../../../../test/testdata/configloader_1_test.yaml", - want: goodConfig, - wantErr: false, - }, } - for _, test := range tests { - handle := utils.NewTestHandle(context.Background()) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + logger := logging.NewTestLogger() - configBytes := []byte(test.configText) - if test.configFile != "" { - configBytes, _ = os.ReadFile(test.configFile) - } + got, _, err := LoadRawConfig([]byte(tc.configText), logger) - got, err := loadRawConfig(configBytes) - if err == nil { - setDefaultsPhaseOne(got) - - err = instantiatePlugins(got.Plugins, handle) - if err == nil { - setDefaultsPhaseTwo(got, handle) - - err = validateSchedulingProfiles(got) + if tc.wantErr { + require.Error(t, err, "Expected LoadRawConfig to fail") + return } - } - checker(t, "tested function", test, got, err) - } -} - -func checker(t *testing.T, function string, test testStruct, got *configapi.EndpointPickerConfig, err error) { - checkError(t, function, test, err) - if err == nil && !test.wantErr { - if diff := cmp.Diff(test.want, got); diff != "" { - t.Errorf("In test %s %s returned unexpected response, diff(-want, +got): %v", test.name, function, diff) - } - } -} - -func checkError(t *testing.T, function string, test testStruct, err error) { - if err != nil { - if !test.wantErr { - t.Fatalf("In test '%s' %s returned unexpected error: %v, want %v", test.name, function, err, test.wantErr) - } - t.Logf("error was %s", err) - } else if test.wantErr { - t.Fatalf("In test %s %s did not return an expected error", test.name, function) + require.NoError(t, err, "Expected LoadRawConfig to succeed") + diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(configapi.EndpointPickerConfig{}, "TypeMeta")) + require.Empty(t, diff, "Config mismatch (-want +got):\n%s", diff) + }) } } -func TestInstantiatePlugins(t *testing.T) { - registerNeededFeatureGates() - logger := logging.NewTestLogger() - rawConfig, _, err := LoadConfigPhaseOne([]byte(successConfigText), logger) - if err != nil { - t.Fatalf("LoadConfigPhaseOne returned unexpected error - %v", err) - } - handle := utils.NewTestHandle(context.Background()) - _, err = LoadConfigPhaseTwo(rawConfig, handle, logger) - if err != nil { - t.Fatalf("LoadConfigPhaseTwo returned unexpected error - %v", err) - } - if len(handle.GetAllPlugins()) == 0 { - t.Fatalf("unexpected empty set of loaded plugins") - } - if t1 := handle.Plugin("test1"); t1 == nil { - t.Fatalf("loaded plugins did not contain test1") - } else if _, ok := t1.(*test1); !ok { - t.Fatalf("loaded plugins returned test1 has the wrong type %#v", t1) - } +// --- Test: Phase 2 (Instantiation, Architecture Injection, Deep Validation) --- - rawConfig, _, err = LoadConfigPhaseOne([]byte(errorBadPluginReferenceParametersText), logger) - if err != nil { - t.Fatalf("LoadConfigPhaseTwo returned unexpected error - %v", err) - } - handle = utils.NewTestHandle(context.Background()) - _, err = LoadConfigPhaseTwo(rawConfig, handle, logger) - if err == nil { - t.Fatalf("LoadConfigPhaseTwo did not return error as expected ") - } -} +func TestInstantiateAndConfigure(t *testing.T) { + // Not parallel because it modifies global plugin registry. + registerTestPlugins(t) -func TestLoadConfig(t *testing.T) { + RegisterFeatureGate(datalayer.FeatureGate) tests := []struct { name string configText string - want *config.Config wantErr bool + validate func(t *testing.T, handle plugins.Handle, cfg *configapi.EndpointPickerConfig) }{ + // --- Success Scenarios --- { - name: "schedulerSuccess", + name: "Success - Complex Scheduler", configText: successSchedulerConfigText, wantErr: false, + validate: func(t *testing.T, handle plugins.Handle, cfg *configapi.EndpointPickerConfig) { + // 1. Verify all explicit plugins exist in the registry + require.NotNil(t, handle.Plugin("testScorer"), "Explicit scorer should be instantiated") + require.NotNil(t, handle.Plugin("maxScorePicker"), "Explicit picker should be instantiated") + require.NotNil(t, handle.Plugin("profileHandler"), "Explicit profile handler should be instantiated") + + // 2. Verify Profile Integrity + // We explicitly defined a picker, so the defaulter should NOT have added a second one. + require.Len(t, cfg.SchedulingProfiles, 1) + require.Len(t, cfg.SchedulingProfiles[0].Plugins, 2, + "Profile should have exactly 2 plugins (Scorer + Explicit Picker)") + + // 3. Verify Weight Propagation + // The YAML specified weight: 50. Ensure it wasn't overwritten by defaults. + scorerRef := cfg.SchedulingProfiles[0].Plugins[0] + require.Equal(t, "testScorer", scorerRef.PluginRef) + require.NotNil(t, scorerRef.Weight) + require.Equal(t, 50, *scorerRef.Weight, "Explicit weight of 50 should be preserved") + }, }, { - name: "successWithNoWeight", + name: "Success - Default Scorer Weight", configText: successWithNoWeightText, wantErr: false, + validate: func(t *testing.T, _ plugins.Handle, cfg *configapi.EndpointPickerConfig) { + require.Len(t, cfg.SchedulingProfiles, 1, "Unexpected profile structure") + require.Len(t, cfg.SchedulingProfiles[0].Plugins, 2, "Expected Scorer + Default Picker") + w := cfg.SchedulingProfiles[0].Plugins[0].Weight + require.NotNil(t, w, "Weight should not be nil") + require.Equal(t, 1, *w, "Expected default scorer weight of 1") + }, }, { - name: "successWithNoProfileHandlers", + name: "Success - Default Profile Handler Injection", configText: successWithNoProfileHandlersText, wantErr: false, + validate: func(t *testing.T, handle plugins.Handle, cfg *configapi.EndpointPickerConfig) { + require.True(t, hasPluginType(handle, profile.SingleProfileHandlerType), + "Defaults: SingleProfileHandler was not injected") + }, }, + + // --- Instantiation Errors --- { - name: "errorBadYaml", - configText: errorBadYamlText, + name: "Error (Instantiation) - Missing Type Field", + configText: errorBadPluginReferenceText, + wantErr: true, + }, + { + name: "Error (Instantiation) - Unknown Plugin Type", + configText: errorBadPluginReferencePluginText, wantErr: true, }, { - name: "errorBadPluginJson", + name: "Error (Instantiation) - Invalid JSON Parameters", configText: errorBadPluginJsonText, wantErr: true, }, { - name: "errorNoProfileName", + name: "Error (Instantiation) - Duplicate Plugin Name", + configText: errorDuplicatePluginText, + wantErr: true, + }, + + // --- Deep Validation Errors --- + { + name: "Error (Deep Validation) - Missing Profile Name", configText: errorNoProfileNameText, wantErr: true, }, { - name: "errorTwoPickers", + name: "Error (Deep Validation) - Missing PluginRef in Profile", + configText: errorBadProfilePluginText, + wantErr: true, + }, + { + name: "Error (Deep Validation) - Profile References Undefined Plugin", + configText: errorBadProfilePluginRefText, + wantErr: true, + }, + { + name: "Error (Deep Validation) - Duplicate Profile Name", + configText: errorDuplicateProfileText, + wantErr: true, + }, + + // --- Architectural Errors --- + { + name: "Error (Architectural) - Two Pickers in One Profile", configText: errorTwoPickersText, wantErr: true, }, { - name: "errorTwoProfileHandlers", + name: "Error (Deep Architectural) - Multiple Profile Handlers", configText: errorTwoProfileHandlersText, wantErr: true, }, { - name: "errorNoProfileHandlers", + name: "Error (Deep Architectural) - Missing Profile Handler", configText: errorNoProfileHandlersText, wantErr: true, }, { - name: "errorMultiProfilesUseSingleProfileHandler", + name: "Error (Deep Architectural) - Multi-Profile with Single Handler", configText: errorMultiProfilesUseSingleProfileHandlerText, wantErr: true, }, - { - name: "errorUnknownFeatureGate", - configText: errorUnknownFeatureGateText, - wantErr: true, - }, } - registerNeededFeatureGates() - registerNeededPlgugins() + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + logger := logging.NewTestLogger() - logger := logging.NewTestLogger() - for _, test := range tests { - rawConfig, _, err := LoadConfigPhaseOne([]byte(test.configText), logger) - if err != nil { - if !test.wantErr { - t.Errorf("LoadConfigPhaseOne returned an unexpected error. error %v", err) - } - t.Logf("error was %s", err) - } else if test.wantErr { - handle := utils.NewTestHandle(context.Background()) - _, err = LoadConfigPhaseTwo(rawConfig, handle, logger) + // 1. Load Raw (Assuming valid yaml/structure for Phase 2 tests) + rawConfig, _, err := LoadRawConfig([]byte(tc.configText), logger) if err != nil { - if !test.wantErr { - t.Errorf("LoadConfigPhaseOne returned an unexpected error. error %v", err) + // If we expected failure (and it failed early in Phase 1), success. + if tc.wantErr { + return } - t.Logf("error was %s", err) - } else if test.wantErr { - t.Errorf("LoadConfig did not return an expected error (%s)", test.name) + require.NoError(t, err, "Setup: LoadRawConfig failed") } - } - } -} -func registerNeededFeatureGates() { - RegisterFeatureGate(datalayer.FeatureGate) -} + // 2. Instantiate & Configure + handle := utils.NewTestHandle(context.Background()) + _, err = InstantiateAndConfigure(rawConfig, handle, logger) -func registerNeededPlgugins() { - plugins.Register(prefix.PrefixCachePluginType, prefix.PrefixCachePluginFactory) - plugins.Register(picker.MaxScorePickerType, picker.MaxScorePickerFactory) - plugins.Register(picker.RandomPickerType, picker.RandomPickerFactory) - plugins.Register(picker.WeightedRandomPickerType, picker.WeightedRandomPickerFactory) - plugins.Register(profile.SingleProfileHandlerType, profile.SingleProfileHandlerFactory) + if tc.wantErr { + require.Error(t, err, "Expected InstantiateAndConfigure to fail") + return + } + require.NoError(t, err, "Expected InstantiateAndConfigure to succeed") + + if tc.validate != nil { + tc.validate(t, handle, rawConfig) + } + }) + } } -func TestNewDetector(t *testing.T) { +// Add this new test function to verify the builder logic specifically +func TestBuildSaturationConfig(t *testing.T) { + t.Parallel() + tests := []struct { - name string - config *configapi.SaturationDetector - expectedConfig saturationdetector.Config + name string + input *configapi.SaturationDetector + expected *saturationdetector.Config }{ { - name: "Valid config", - config: &configapi.SaturationDetector{ - QueueDepthThreshold: 10, - KVCacheUtilThreshold: 0.8, - MetricsStalenessThreshold: metav1.Duration{Duration: 100 * time.Millisecond}, + name: "Valid Configuration", + input: &configapi.SaturationDetector{ + QueueDepthThreshold: 20, + KVCacheUtilThreshold: 0.9, + MetricsStalenessThreshold: metav1.Duration{Duration: 500 * time.Millisecond}, }, - expectedConfig: saturationdetector.Config{ - QueueDepthThreshold: 10, - KVCacheUtilThreshold: 0.8, - MetricsStalenessThreshold: 100 * time.Millisecond, + expected: &saturationdetector.Config{ + QueueDepthThreshold: 20, + KVCacheUtilThreshold: 0.9, + MetricsStalenessThreshold: 500 * time.Millisecond, }, }, { - name: "invalid thresholds, fallback to default", - config: &configapi.SaturationDetector{ - QueueDepthThreshold: -1, - KVCacheUtilThreshold: -5.0, - MetricsStalenessThreshold: metav1.Duration{Duration: 0 * time.Second}, - }, - expectedConfig: saturationdetector.Config{ + name: "Nil Input (Defaults)", + input: nil, + expected: &saturationdetector.Config{ QueueDepthThreshold: saturationdetector.DefaultQueueDepthThreshold, KVCacheUtilThreshold: saturationdetector.DefaultKVCacheUtilThreshold, MetricsStalenessThreshold: saturationdetector.DefaultMetricsStalenessThreshold, }, }, { - name: "kv cache threshold above range, fallback to default", - config: &configapi.SaturationDetector{ - QueueDepthThreshold: 10, + name: "Invalid Values (Fallback to Defaults)", + input: &configapi.SaturationDetector{ + QueueDepthThreshold: -5, KVCacheUtilThreshold: 1.5, - MetricsStalenessThreshold: metav1.Duration{Duration: 100 * time.Millisecond}, + MetricsStalenessThreshold: metav1.Duration{Duration: -10 * time.Second}, }, - expectedConfig: saturationdetector.Config{ - QueueDepthThreshold: 10, + expected: &saturationdetector.Config{ + QueueDepthThreshold: saturationdetector.DefaultQueueDepthThreshold, KVCacheUtilThreshold: saturationdetector.DefaultKVCacheUtilThreshold, - MetricsStalenessThreshold: 100 * time.Millisecond, + MetricsStalenessThreshold: saturationdetector.DefaultMetricsStalenessThreshold, }, }, } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // validate configuration values are loaded from configuration struct properly, including the use of default values when provided value is invalid. - sdConfig := loadSaturationDetectorConfig(test.config) - if diff := cmp.Diff(test.expectedConfig, *sdConfig); diff != "" { - t.Errorf("Unexpected output (-want +got): %v", diff) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := buildSaturationConfig(tc.input) + if diff := cmp.Diff(tc.expected, got); diff != "" { + t.Errorf("buildSaturationConfig mismatch (-want +got):\n%s", diff) } }) } } -// The following multi-line string constants, cause false positive lint errors (dupword) - -// valid configuration -// -//nolint:dupword -const successConfigText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: 10 -- name: profileHandler - type: test-profile-handler -- type: test-two - parameters: - blockSize: 32 -- name: testPicker - type: test-picker -schedulingProfiles: -- name: default - plugins: - - pluginRef: test1 - - pluginRef: test-two - weight: 50 - - pluginRef: testPicker -featureGates: -- dataLayer -saturationDetector: - metricsStalenessThreshold: 150ms -` - -// success with missing scheduling profiles -// -//nolint:dupword -const successNoProfilesText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: 10 -` - -// YAML does not follow expected structure of config -// -//nolint:dupword -const errorBadYamlText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- testing 1 2 3 -` - -// missing required Plugin type -// -//nolint:dupword -const errorBadPluginReferenceText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- parameters: - a: 1234 -` - -// plugin type does not exist -// -//nolint:dupword -const errorBadPluginReferencePluginText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: testx - type: test-x -- name: profileHandler - type: test-profile-handler -` - -// missing required scheduling profile name -// -//nolint:dupword -const errorNoProfileNameText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: 10 -- name: profileHandler - type: test-profile-handler -schedulingProfiles: -- plugins: - - pluginRef: test1 -` - -// missing required plugin reference name, only weight is provided -// -//nolint:dupword -const errorBadProfilePluginText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: test-profile-handler -schedulingProfiles: -- name: default - plugins: - - weight: 10 -` - -// reference a non-existent plugin -// -//nolint:dupword -const errorBadProfilePluginRefText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: test-profile-handler -schedulingProfiles: -- name: default - plugins: - - pluginRef: plover -` - -// invalid parameters (string provided where int is expected) -// -//nolint:dupword -const errorBadPluginReferenceParametersText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: asdf -- name: profileHandler - type: test-profile-handler -schedulingProfiles: -- name: default - plugins: - - pluginRef: test1 -` - -// duplicate names in plugin list -// -//nolint:dupword -const errorDuplicatePluginText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: 10 -- name: test1 - type: test-one - parameters: - threshold: 20 -- name: profileHandler - type: test-profile-handler -schedulingProfiles: -- name: default - plugins: - - pluginRef: test1 -` - -// duplicate scheduling profile name -// -//nolint:dupword -const errorDuplicateProfileText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: 10 -- name: test2 - type: test-one - parameters: - threshold: 20 -- name: profileHandler - type: test-profile-handler -schedulingProfiles: -- name: default - plugins: - - pluginRef: test1 -- name: default - plugins: - - pluginRef: test2 -` - -// error with an unknown feature gate -// -//nolint:dupword -const errorUnknownFeatureGateText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: test1 - type: test-one - parameters: - threshold: 10 -featureGates: -- qwerty -` - -// compile-time type validation -var _ framework.Filter = &test1{} +// --- Helpers & Mocks --- -type test1 struct { - typedName plugins.TypedName - Threshold int `json:"threshold"` -} - -func newTest1() *test1 { - return &test1{ - typedName: plugins.TypedName{Type: test1Type, Name: "test-1"}, - } -} - -func (f *test1) TypedName() plugins.TypedName { - return f.typedName -} - -// Filter filters out pods that doesn't meet the filter criteria. -func (f *test1) Filter(_ context.Context, _ *types.CycleState, _ *types.LLMRequest, pods []types.Pod) []types.Pod { - return pods -} - -// compile-time type validation -var _ framework.Scorer = &test2{} - -type test2 struct { - typedName plugins.TypedName -} - -func newTest2() *test2 { - return &test2{ - typedName: plugins.TypedName{Type: test2Type, Name: "test-2"}, +func hasPluginType(handle plugins.Handle, typeName string) bool { + for _, p := range handle.GetAllPlugins() { + if p.TypedName().Type == typeName { + return true + } } + return false } -func (m *test2) TypedName() plugins.TypedName { - return m.typedName +type mockPlugin struct { + t plugins.TypedName } -func (m *test2) Score(_ context.Context, _ *types.CycleState, _ *types.LLMRequest, _ []types.Pod) map[types.Pod]float64 { - return map[types.Pod]float64{} -} +func (m *mockPlugin) TypedName() plugins.TypedName { return m.t } -// compile-time type validation -var _ framework.Picker = &testPicker{} +// Mock Scorer +type mockScorer struct{ mockPlugin } -type testPicker struct { - typedName plugins.TypedName -} - -func newTestPicker() *testPicker { - return &testPicker{ - typedName: plugins.TypedName{Type: testPickerType, Name: "test-picker"}, - } -} - -func (p *testPicker) TypedName() plugins.TypedName { - return p.typedName -} - -func (p *testPicker) Pick(_ context.Context, _ *types.CycleState, _ []*types.ScoredPod) *types.ProfileRunResult { +func (m *mockScorer) Score(context.Context, *types.CycleState, *types.LLMRequest, []types.Pod) map[types.Pod]float64 { return nil } -// compile-time type validation -var _ framework.ProfileHandler = &testProfileHandler{} - -type testProfileHandler struct { - typedName plugins.TypedName -} +// Mock Picker +type mockPicker struct{ mockPlugin } -func newTestProfileHandler() *testProfileHandler { - return &testProfileHandler{ - typedName: plugins.TypedName{Type: testProfileHandlerType, Name: "test-profile-handler"}, - } +func (m *mockPicker) Pick(context.Context, *types.CycleState, []*types.ScoredPod) *types.ProfileRunResult { + return nil } -func (p *testProfileHandler) TypedName() plugins.TypedName { - return p.typedName -} +// Mock Handler +type mockHandler struct{ mockPlugin } -func (p *testProfileHandler) Pick(_ context.Context, _ *types.CycleState, _ *types.LLMRequest, _ map[string]*framework.SchedulerProfile, _ map[string]*types.ProfileRunResult) map[string]*framework.SchedulerProfile { +func (m *mockHandler) Pick( + context.Context, + *types.CycleState, + *types.LLMRequest, + map[string]*framework.SchedulerProfile, + map[string]*types.ProfileRunResult, +) map[string]*framework.SchedulerProfile { return nil } - -func (p *testProfileHandler) ProcessResults(_ context.Context, _ *types.CycleState, _ *types.LLMRequest, _ map[string]*types.ProfileRunResult) (*types.SchedulingResult, error) { +func (m *mockHandler) ProcessResults( + context.Context, + *types.CycleState, + *types.LLMRequest, + map[string]*types.ProfileRunResult, +) (*types.SchedulingResult, error) { return nil, nil } -func registerTestPlugins() { - plugins.Register(test1Type, - func(_ string, parameters json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { - result := newTest1() - err := json.Unmarshal(parameters, result) - return result, err - }, - ) - - plugins.Register(test2Type, - func(_ string, _ json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { - return newTest2(), nil - }, - ) - - plugins.Register(testPickerType, - func(_ string, _ json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { - return newTestPicker(), nil - }, - ) - - plugins.Register(testProfileHandlerType, - func(_ string, _ json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { - return newTestProfileHandler(), nil - }, - ) -} - -// valid configuration -// -//nolint:dupword -const successSchedulerConfigText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: prefixCacheScorer - type: prefix-cache-scorer - parameters: - blockSize: 32 -- name: maxScorePicker - type: max-score-picker -- name: profileHandler - type: single-profile-handler -schedulingProfiles: -- name: default - plugins: - - pluginRef: prefixCacheScorer - weight: 50 - - pluginRef: maxScorePicker -featureGates: -- dataLayer -` +func registerTestPlugins(t *testing.T) { + t.Helper() -// valid configuration, with default weight for scorer -// -//nolint:dupword -const successWithNoWeightText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: single-profile-handler -- name: prefixCacheScorer - type: prefix-cache-scorer - parameters: - blockSize: 32 -schedulingProfiles: -- name: default - plugins: - - pluginRef: prefixCacheScorer -` + // Helper to generate simple factories. + register := func(name string, factory plugins.FactoryFunc) { + plugins.Register(name, factory) + } -// valid configuration using default profile handler -// -//nolint:dupword -const successWithNoProfileHandlersText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: maxScore - type: max-score-picker -schedulingProfiles: -- name: default - plugins: - - pluginRef: maxScore -` + mockFactory := func(tType string) plugins.FactoryFunc { + return func(name string, _ json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { + return &mockPlugin{t: plugins.TypedName{Name: name, Type: tType}}, nil + } + } -// invalid parameter configuration for plugin (string passed, in expected) -// -//nolint:dupword -const errorBadPluginJsonText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: single-profile-handler -- name: prefixCacheScorer - type: prefix-cache-scorer - parameters: - blockSize: asdf -schedulingProfiles: -- name: default - plugins: - - pluginRef: prefixCacheScorer - weight: 50 -` + // Register standard test mocks. + register(testPluginType, mockFactory(testPluginType)) -// multiple pickers in scheduling profile -// -//nolint:dupword -const errorTwoPickersText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: single-profile-handler -- name: maxScore - type: max-score-picker -- name: random - type: random-picker -schedulingProfiles: -- name: default - plugins: - - pluginRef: maxScore - - pluginRef: random -` + plugins.Register(testScorerType, func(name string, params json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { + // Attempt to unmarshal to trigger errors for invalid JSON in tests. + if len(params) > 0 { + var p struct { + BlockSize int `json:"blockSize"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + } + return &mockScorer{mockPlugin{t: plugins.TypedName{Name: name, Type: testScorerType}}}, nil + }) -// multiple profile handlers when only one is allowed -// -//nolint:dupword -const errorTwoProfileHandlersText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: single-profile-handler -- name: secondProfileHandler - type: single-profile-handler -- name: maxScore - type: max-score-picker -schedulingProfiles: -- name: default - plugins: - - pluginRef: maxScore -` + plugins.Register(testPickerType, func(name string, _ json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { + return &mockPicker{mockPlugin{t: plugins.TypedName{Name: name, Type: testPickerType}}}, nil + }) -// missing required profile handler -// -//nolint:dupword -const errorNoProfileHandlersText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: maxScore - type: max-score-picker -schedulingProfiles: -- name: default - plugins: - - pluginRef: maxScore -- name: prof2 - plugins: - - pluginRef: maxScore -` + plugins.Register(testProfileHandler, func(name string, _ json.RawMessage, _ plugins.Handle) (plugins.Plugin, error) { + return &mockHandler{mockPlugin{t: plugins.TypedName{Name: name, Type: testProfileHandler}}}, nil + }) -// multiple profiles using SingleProfileHandler -// -//nolint:dupword -const errorMultiProfilesUseSingleProfileHandlerText = ` -apiVersion: inference.networking.x-k8s.io/v1alpha1 -kind: EndpointPickerConfig -plugins: -- name: profileHandler - type: single-profile-handler -- name: maxScore - type: max-score-picker -schedulingProfiles: -- name: default - plugins: - - pluginRef: maxScore -- name: prof2 - plugins: - - pluginRef: maxScore -` + // Ensure system defaults are registered too. + plugins.Register(picker.MaxScorePickerType, picker.MaxScorePickerFactory) + plugins.Register(profile.SingleProfileHandlerType, profile.SingleProfileHandlerFactory) +} diff --git a/pkg/epp/config/loader/testdata_test.go b/pkg/epp/config/loader/testdata_test.go new file mode 100644 index 000000000..052664b10 --- /dev/null +++ b/pkg/epp/config/loader/testdata_test.go @@ -0,0 +1,335 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package loader + +// --- Valid Configurations --- + +// successConfigText represents a fully populated, valid configuration. +// It uses a mix of explicit names and type-derived names. +const successConfigText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: test1 + type: test-plugin + parameters: + threshold: 10 +- name: profileHandler + type: test-profile-handler +- type: test-scorer + parameters: + blockSize: 32 +- name: testPicker + type: test-picker +schedulingProfiles: +- name: default + plugins: + - pluginRef: test1 + - pluginRef: test-scorer + weight: 50 + - pluginRef: testPicker +featureGates: +- dataLayer +saturationDetector: + queueDepthThreshold: 10 + kvCacheUtilThreshold: 0.8 + metricsStalenessThreshold: 100ms +` + +// successNoProfilesText represents a valid config with plugins but no profiles. +// The loader should apply the system default profile automatically. +const successNoProfilesText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: test1 + type: test-plugin + parameters: + threshold: 10 +` + +// successSchedulerConfigText represents a complex scheduler setup. +const successSchedulerConfigText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: testScorer + type: test-scorer + parameters: + blockSize: 32 +- name: maxScorePicker + type: max-score-picker +- name: profileHandler + type: single-profile-handler +schedulingProfiles: +- name: default + plugins: + - pluginRef: testScorer + weight: 50 + - pluginRef: maxScorePicker +featureGates: +- dataLayer +` + +// successWithNoWeightText tests that scorers receive the default weight if unspecified. +const successWithNoWeightText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: single-profile-handler +- name: testScorer + type: test-scorer + parameters: + blockSize: 32 +schedulingProfiles: +- name: default + plugins: + - pluginRef: testScorer +` + +// successWithNoProfileHandlersText tests that a default profile handler is injected. +const successWithNoProfileHandlersText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: maxScore + type: max-score-picker +schedulingProfiles: +- name: default + plugins: + - pluginRef: maxScore +` + +// --- Invalid Configurations (Syntax/Structure) --- + +// errorBadYamlText contains invalid YAML syntax. +const errorBadYamlText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- testing 1 2 3 +` + +// errorBadPluginReferenceText is missing the required 'type' field. +const errorBadPluginReferenceText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- parameters: + a: 1234 +` + +// errorBadPluginReferencePluginText references a plugin type that does not exist in the registry. +const errorBadPluginReferencePluginText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: testx + type: unknown-plugin-type +- name: profileHandler + type: test-profile-handler +` + +// errorBadPluginJsonText has invalid JSON in parameters (string where int expected). +const errorBadPluginJsonText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: single-profile-handler +- name: testScorer + type: test-scorer + parameters: + blockSize: asdf +schedulingProfiles: +- name: default + plugins: + - pluginRef: testScorer + weight: 50 +` + +// errorUnknownFeatureGateText includes a feature gate not defined in the code. +const errorUnknownFeatureGateText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: test1 + type: test-plugin + parameters: + threshold: 10 +featureGates: +- unknown-gate +` + +// --- Invalid Configurations (Logical/Architectural) --- + +// errorNoProfileNameText is missing the required profile name. +const errorNoProfileNameText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: test1 + type: test-plugin + parameters: + threshold: 10 +- name: profileHandler + type: test-profile-handler +schedulingProfiles: +- plugins: + - pluginRef: test1 +` + +// errorBadProfilePluginText is missing the required pluginRef. +const errorBadProfilePluginText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: test-profile-handler +schedulingProfiles: +- name: default + plugins: + - weight: 10 +` + +// errorBadProfilePluginRefText references a plugin name that wasn't defined. +const errorBadProfilePluginRefText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: test-profile-handler +schedulingProfiles: +- name: default + plugins: + - pluginRef: non-existent-plugin +` + +// errorDuplicatePluginText defines the same plugin name twice. +const errorDuplicatePluginText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: test1 + type: test-plugin + parameters: + threshold: 10 +- name: test1 + type: test-plugin + parameters: + threshold: 20 +- name: profileHandler + type: test-profile-handler +schedulingProfiles: +- name: default + plugins: + - pluginRef: test1 +` + +// errorDuplicateProfileText defines the same profile name twice. +const errorDuplicateProfileText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: test1 + type: test-plugin + parameters: + threshold: 10 +- name: test2 + type: test-plugin + parameters: + threshold: 20 +- name: profileHandler + type: test-profile-handler +schedulingProfiles: +- name: default + plugins: + - pluginRef: test1 +- name: default + plugins: + - pluginRef: test2 +` + +// errorTwoPickersText defines multiple pickers in a single profile (invalid). +const errorTwoPickersText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: single-profile-handler +- name: maxScore + type: max-score-picker +- name: random + type: test-picker +schedulingProfiles: +- name: default + plugins: + - pluginRef: maxScore + - pluginRef: random +` + +// errorTwoProfileHandlersText defines multiple profile handlers (global singleton). +const errorTwoProfileHandlersText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: single-profile-handler +- name: secondProfileHandler + type: single-profile-handler +- name: maxScore + type: max-score-picker +schedulingProfiles: +- name: default + plugins: + - pluginRef: maxScore +` + +// errorNoProfileHandlersText fails to define any profile handler. +const errorNoProfileHandlersText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: maxScore + type: max-score-picker +schedulingProfiles: +- name: default + plugins: + - pluginRef: maxScore +- name: prof2 + plugins: + - pluginRef: maxScore +` + +// errorMultiProfilesUseSingleProfileHandlerText uses SingleProfileHandler with multiple profiles. +const errorMultiProfilesUseSingleProfileHandlerText = ` +apiVersion: inference.networking.x-k8s.io/v1alpha1 +kind: EndpointPickerConfig +plugins: +- name: profileHandler + type: single-profile-handler +- name: maxScore + type: max-score-picker +schedulingProfiles: +- name: default + plugins: + - pluginRef: maxScore +- name: prof2 + plugins: + - pluginRef: maxScore +` From 2c7ada77bb9ef6160479b7692beb659928cd7058 Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Fri, 5 Dec 2025 01:04:23 +0000 Subject: [PATCH 3/3] runner: update entrypoint to use new config loader Updates the EPP runner to use the refactored loader functions. The flow is updated to: 1. `loader.LoadRawConfig` to parse bytes and retrieve feature gates. 2. Initialize the plugin handle. 3. `loader.InstantiateAndConfigure` to build the final configuration. --- cmd/epp/runner/runner.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/epp/runner/runner.go b/cmd/epp/runner/runner.go index f02a36557..13274ca95 100644 --- a/cmd/epp/runner/runner.go +++ b/cmd/epp/runner/runner.go @@ -468,7 +468,7 @@ func (r *Runner) parseConfigurationPhaseOne(ctx context.Context) (*configapi.End r.registerInTreePlugins() - rawConfig, featureGates, err := loader.LoadConfigPhaseOne(configBytes, logger) + rawConfig, featureGates, err := loader.LoadRawConfig(configBytes, logger) if err != nil { return nil, fmt.Errorf("failed to parse config - %w", err) } @@ -494,7 +494,7 @@ func makePodListFunc(ds datastore.Datastore) func() []types.NamespacedName { func (r *Runner) parseConfigurationPhaseTwo(ctx context.Context, rawConfig *configapi.EndpointPickerConfig, ds datastore.Datastore) (*config.Config, error) { logger := log.FromContext(ctx) handle := plugins.NewEppHandle(ctx, makePodListFunc(ds)) - cfg, err := loader.LoadConfigPhaseTwo(rawConfig, handle, logger) + cfg, err := loader.InstantiateAndConfigure(rawConfig, handle, logger) if err != nil { return nil, fmt.Errorf("failed to load the configuration - %w", err)