diff --git a/cmd/epp/runner/runner.go b/cmd/epp/runner/runner.go index 5c3394764..b460cd822 100644 --- a/cmd/epp/runner/runner.go +++ b/cmd/epp/runner/runner.go @@ -497,7 +497,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)) + handle := plugins.NewEppHandle(ctx, rawConfig.Plugins, nil, makePodListFunc(ds)) cfg, err := loader.InstantiateAndConfigure(rawConfig, handle, logger) if err != nil { @@ -513,7 +513,6 @@ func (r *Runner) parseConfigurationPhaseTwo(ctx context.Context, rawConfig *conf return nil, errors.New("failed to load the configuration - prepare data plugins have cyclic dependencies") } - // Handler deprecated configuration options r.deprecatedConfigurationHelper(cfg, logger) logger.Info("loaded configuration from file/text successfully") diff --git a/pkg/epp/config/loader/configloader.go b/pkg/epp/config/loader/configloader.go index 03126f3b9..047cd689b 100644 --- a/pkg/epp/config/loader/configloader.go +++ b/pkg/epp/config/loader/configloader.go @@ -121,12 +121,18 @@ func instantiatePlugins(configuredPlugins []configapi.PluginSpec, handle plugins } pluginNames.Insert(spec.Name) - factory, ok := plugins.Registry[spec.Type] + reg, 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 this is a Transient plugin, we DO NOT instantiate it here. + // It sits in the Handle.PluginSpecs() waiting for the Factory to create instances on demand. + if reg.Lifecycle == plugins.LifecycleTransient { + continue + } + + plugin, err := reg.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) } diff --git a/pkg/epp/config/loader/defaults.go b/pkg/epp/config/loader/defaults.go index d20f0d0a1..d6415c675 100644 --- a/pkg/epp/config/loader/defaults.go +++ b/pkg/epp/config/loader/defaults.go @@ -142,12 +142,12 @@ func registerDefaultPlugin( pluginType string, ) error { name := pluginType - factory, ok := plugins.Registry[pluginType] + reg, ok := plugins.Registry[pluginType] if !ok { return fmt.Errorf("plugin type '%s' not found in registry", pluginType) } - plugin, err := factory(name, nil, handle) + plugin, err := reg.Factory(name, nil, handle) if err != nil { return fmt.Errorf("failed to instantiate default plugin '%s': %w", name, err) } diff --git a/pkg/epp/plugins/doc.go b/pkg/epp/plugins/doc.go new file mode 100644 index 000000000..d3f4b57e5 --- /dev/null +++ b/pkg/epp/plugins/doc.go @@ -0,0 +1,51 @@ +/* +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 plugins provides the core extensibility framework for the Endpoint Picker (EPP). +// It enables the system to be composed of modular, pluggable components that handle everything from +// request scheduling to flow control and data enrichment. +// +// # Core Concepts +// +// 1. Registry & Lifecycle +// +// Plugins are registered globally via the Registry. The system supports two lifecycles: +// +// - Singleton (Default): Instantiated once at startup. +// - Transient: Instantiated on-demand at runtime via a Factory. +// +// 2. Configuration (Handle) +// +// The Handle serves as the bridge between the configuration (YAML) and the runtime. +// It holds "Blueprints" (config for Transient plugins) and references to "Instances" (active +// Singleton plugins). +// +// 3. Factory +// +// The PluginFactory is the mechanism for creating Transient plugins. It resolves a configuration +// blueprint from the Handle and instantiates a new, distinct plugin instance, allowing for unique +// runtime identities (e.g., "tenant-a-queue"). +// +// Architectural Distinction: The DAG vs. Flow Control +// +// The EPP Request Control DAG operates exclusively on Singleton plugins. +// It relies on a static topological sort performed at startup. +// +// Transient plugins (LifecycleTransient) do NOT participate in the global Request Control DAG. +// They live inside specific subsystems (like the Flow Control Layer) and have their own independent +// lifecycles managed by that subsystem. They are not accessible via handle.GetAllPlugins() and +// cannot be configured as dependencies for PrepareData plugins. +package plugins diff --git a/pkg/epp/plugins/factory.go b/pkg/epp/plugins/factory.go new file mode 100644 index 000000000..78488be27 --- /dev/null +++ b/pkg/epp/plugins/factory.go @@ -0,0 +1,90 @@ +/* +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 plugins + +import ( + "fmt" +) + +// PluginFactory abstracts the creation of transient plugin instances. +type PluginFactory interface { + // NewPlugin creates a fresh instance of a plugin based on a configuration blueprint. + // + // Arguments: + // - blueprintName: The name of the PluginSpec in the configuration (e.g., "standard-fairness-policy"). + // - instanceAlias: An optional runtime name for this specific instance (e.g., "tenant-a"). + // If empty, the blueprintName is used. + NewPlugin(blueprintName string, instanceAlias string) (Plugin, error) +} + +// EPPPluginFactory is the concrete implementation of PluginFactory. +// It ties together the configuration (Handle) and the implementation (Registry). +type EPPPluginFactory struct { + handle Handle +} + +// NewEPPPluginFactory returns a new factory instance. +func NewEPPPluginFactory(handle Handle) *EPPPluginFactory { + return &EPPPluginFactory{handle: handle} +} + +// NewPlugin implements PluginFactory. +func (f *EPPPluginFactory) NewPlugin(blueprintName string, instanceAlias string) (Plugin, error) { + spec := f.handle.PluginSpec(blueprintName) + if spec == nil { + return nil, fmt.Errorf("plugin blueprint %q not found in configuration", blueprintName) + } + + reg, ok := Registry[spec.Type] + if !ok { + return nil, fmt.Errorf("plugin type %q (referenced by blueprint %q) is not registered", spec.Type, blueprintName) + } + + // Determine runtime identity. This ensures that structured logs and internal maps can distinguish between different + // instances of the same plugin type. + finalName := spec.Name + if instanceAlias != "" { + finalName = instanceAlias + } + + plugin, err := reg.Factory(finalName, spec.Parameters, f.handle) + if err != nil { + return nil, fmt.Errorf("failed to instantiate plugin %q (type %s): %w", finalName, spec.Type, err) + } + + return plugin, nil +} + +// NewPluginByType is a helper to create a plugin and assert its type in one step. +func NewPluginByType[T Plugin](factory PluginFactory, blueprintName string, instanceAlias string) (T, error) { + var zero T + + rawPlugin, err := factory.NewPlugin(blueprintName, instanceAlias) + if err != nil { + return zero, err + } + + plugin, ok := rawPlugin.(T) + if !ok { + return zero, fmt.Errorf( + "plugin created from blueprint %q is type %T, but expected %T", + blueprintName, rawPlugin, zero, + ) + } + + return plugin, nil +} diff --git a/pkg/epp/plugins/factory_test.go b/pkg/epp/plugins/factory_test.go new file mode 100644 index 000000000..6a72b16b2 --- /dev/null +++ b/pkg/epp/plugins/factory_test.go @@ -0,0 +1,176 @@ +/* +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 plugins + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" +) + +// transientPlugin is a mock implementation that captures how it was created. +type transientPlugin struct { + Name string + Parameters string +} + +func (p *transientPlugin) TypedName() TypedName { + return TypedName{Type: "transientPlugin", Name: p.Name} +} + +// transientFactory creates transientPlugin instances. +func transientFactory(name string, parameters json.RawMessage, _ Handle) (Plugin, error) { + if string(parameters) == `"fail-me"` { + return nil, errors.New("intentional factory failure") + } + return &transientPlugin{ + Name: name, + Parameters: string(parameters), + }, nil +} + +func TestEPPPluginFactory_NewPlugin(t *testing.T) { + // Register a known type for testing factories. + // We use a unique name to ensure parallel safety if other tests use the registry. + const factoryType = "test-transient-factory-type" + RegisterWithMetadata(factoryType, PluginRegistration{ + Factory: transientFactory, + Lifecycle: LifecycleTransient, + }) + + t.Parallel() + + specs := []configapi.PluginSpec{ + { + Name: "blueprint-default", + Type: factoryType, + Parameters: json.RawMessage(`{"key": "val"}`), + }, + { + Name: "blueprint-broken-factory", + Type: factoryType, + Parameters: json.RawMessage(`"fail-me"`), + }, + { + Name: "blueprint-missing-type", + Type: "unknown-type", + }, + } + + handle := NewEppHandle(context.Background(), specs, nil, nil) + factory := NewEPPPluginFactory(handle) + + tests := []struct { + name string + blueprintName string + instanceAlias string + expectErr bool + errorContains string + expectedName string // The name the plugin instance should think it has. + expectedParams string + }{ + { + name: "success_standard_creation", + blueprintName: "blueprint-default", + instanceAlias: "", // No alias + expectErr: false, + expectedName: "blueprint-default", // Should default to blueprint name. + expectedParams: `{"key": "val"}`, + }, + { + name: "success_with_instance_alias", + blueprintName: "blueprint-default", + instanceAlias: "tenant-a-queue", + expectErr: false, + expectedName: "tenant-a-queue", // Should take the alias. + expectedParams: `{"key": "val"}`, + }, + { + name: "fail_blueprint_not_found", + blueprintName: "non-existent-blueprint", + expectErr: true, + errorContains: "blueprint \"non-existent-blueprint\" not found", + }, + { + name: "fail_plugin_type_not_registered", + blueprintName: "blueprint-missing-type", + expectErr: true, + errorContains: "plugin type \"unknown-type\" (referenced by blueprint \"blueprint-missing-type\") is not registered", + }, + { + name: "fail_factory_returns_error", + blueprintName: "blueprint-broken-factory", + expectErr: true, + errorContains: "failed to instantiate plugin", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + plugin, err := factory.NewPlugin(tc.blueprintName, tc.instanceAlias) + + if tc.expectErr { + require.Error(t, err) + assert.Nil(t, plugin) + assert.Contains(t, err.Error(), tc.errorContains) + } else { + require.NoError(t, err) + require.NotNil(t, plugin) + + // Cast to verify internal state + p, ok := plugin.(*transientPlugin) + require.True(t, ok, "plugin should be of type *transientPlugin") + assert.Equal(t, tc.expectedName, p.Name, "plugin name should match expected identity") + assert.JSONEq(t, tc.expectedParams, p.Parameters, "parameters should be passed through correctly") + } + }) + } +} + +func TestNewPluginByType_GenericHelper(t *testing.T) { + const helperType = "test-helper-type" + RegisterWithMetadata(helperType, PluginRegistration{ + Factory: transientFactory, + Lifecycle: LifecycleTransient, + }) + + t.Parallel() + + specs := []configapi.PluginSpec{{Name: "bp", Type: helperType}} + handle := NewEppHandle(context.Background(), specs, nil, nil) + factory := NewEPPPluginFactory(handle) + + t.Run("success_cast", func(t *testing.T) { + p, err := NewPluginByType[*transientPlugin](factory, "bp", "alias") + require.NoError(t, err) + assert.Equal(t, "alias", p.Name) + }) + + t.Run("fail_cast_mismatch", func(t *testing.T) { + // Try to cast the transientPlugin to mockPluginImpl (which it is not). + _, err := NewPluginByType[*mockPluginImpl](factory, "bp", "alias") + require.Error(t, err) + assert.Contains(t, err.Error(), "is type *plugins.transientPlugin, but expected *plugins.mockPluginImpl") + }) +} diff --git a/pkg/epp/plugins/handle.go b/pkg/epp/plugins/handle.go index c6e9c0dbe..6bc13cee7 100644 --- a/pkg/epp/plugins/handle.go +++ b/pkg/epp/plugins/handle.go @@ -21,104 +21,138 @@ import ( "fmt" "k8s.io/apimachinery/pkg/types" + + configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" ) -// Handle provides plugins a set of standard data and tools to work with +// Handle provides plugins with access to global EPP state, configuration blueprints, and other singleton plugin +// instances. type Handle interface { - // Context returns a context the plugins can use, if they need one + // Context returns the root context for the EPP. Context() context.Context + // PluginSpec returns the raw configuration blueprint for the named plugin. + // Returns nil if no such blueprint exists. + PluginSpec(name string) *configapi.PluginSpec + + // HandlePlugins embeds access to Singleton plugin instances. HandlePlugins - // PodList lists pods. + // PodList returns the current snapshot of ready backend pods. PodList() []types.NamespacedName } -// HandlePlugins defines a set of APIs to work with instantiated plugins +// HandlePlugins defines the API for accessing instantiated Singleton plugins. type HandlePlugins interface { - // Plugin returns the named plugin instance + // Plugin returns the named plugin instance, or nil if not found. Plugin(name string) Plugin - // AddPlugin adds a plugin to the set of known plugin instances + // AddPlugin adds a plugin to the set of known plugin instances. + // Note: This operation modifies the internal map and is not thread-safe. + // It should generally be used only during initialization or in single-threaded contexts. AddPlugin(name string, plugin Plugin) - // GetAllPlugins returns all of the known plugins + // GetAllPlugins returns a slice of all registered Singleton plugins. GetAllPlugins() []Plugin - // GetAllPluginsWithNames returns all of the known plugins with their names + // GetAllPluginsWithNames returns a map of all registered Singleton plugins, keyed by TypedName().Name. GetAllPluginsWithNames() map[string]Plugin } -// PodListFunc is a function type that filters and returns a list of pod metrics +// PodListFunc is a function type that returns a filtered list of pod names. type PodListFunc func() []types.NamespacedName -// eppHandle is an implementation of the interface plugins.Handle +// eppHandle implements the Handle interface. +// Concurrency Note: The pluginSpecs and plugins maps are populated at startup and are strictly read-only thereafter. +// No mutexes are required for read access. type eppHandle struct { - ctx context.Context - HandlePlugins - podList PodListFunc + ctx context.Context + pluginSpecs map[string]*configapi.PluginSpec + plugins map[string]Plugin + podList PodListFunc +} + +// NewEppHandle creates a new, immutable handle. +func NewEppHandle( + ctx context.Context, + specs []configapi.PluginSpec, + plugins map[string]Plugin, + podList PodListFunc, +) Handle { + // Deep copy specs into a map for O(1) lookup. + specMap := make(map[string]*configapi.PluginSpec, len(specs)) + for i := range specs { + s := specs[i] + specMap[s.Name] = &s + } + + if plugins == nil { + plugins = make(map[string]Plugin) + } + + return &eppHandle{ + ctx: ctx, + pluginSpecs: specMap, + plugins: plugins, + podList: podList, + } } -// Context returns a context the plugins can use, if they need one +// Context returns the root context associated with this handle. func (h *eppHandle) Context() context.Context { return h.ctx } -// eppHandlePlugins implements the set of APIs to work with instantiated plugins -type eppHandlePlugins struct { - plugins map[string]Plugin +// PluginSpec retrieves the configuration blueprint for a given plugin name. +func (h *eppHandle) PluginSpec(name string) *configapi.PluginSpec { + return h.pluginSpecs[name] } -// Plugin returns the named plugin instance -func (h *eppHandlePlugins) Plugin(name string) Plugin { +// Plugin retrieves a singleton plugin instance by name. +func (h *eppHandle) Plugin(name string) Plugin { return h.plugins[name] } -// AddPlugin adds a plugin to the set of known plugin instances -func (h *eppHandlePlugins) AddPlugin(name string, plugin Plugin) { +// AddPlugin registers a new singleton plugin instance. +func (h *eppHandle) AddPlugin(name string, plugin Plugin) { h.plugins[name] = plugin } -// GetAllPlugins returns all of the known plugins -func (h *eppHandlePlugins) GetAllPlugins() []Plugin { - result := make([]Plugin, 0) +// GetAllPlugins returns a list of all registered singleton plugins. +func (h *eppHandle) GetAllPlugins() []Plugin { + result := make([]Plugin, 0, len(h.plugins)) for _, plugin := range h.plugins { result = append(result, plugin) } return result } -// GetAllPluginsWithNames returns al of the known plugins with their names -func (h *eppHandlePlugins) GetAllPluginsWithNames() map[string]Plugin { +// GetAllPluginsWithNames returns a map of all registered Singleton plugins, keyed by TypedName().Name. +func (h *eppHandle) GetAllPluginsWithNames() map[string]Plugin { return h.plugins } -// PodList lists pods. +// PodList returns the list of currently ready pods from the underlying watcher. func (h *eppHandle) PodList() []types.NamespacedName { - return h.podList() -} - -func NewEppHandle(ctx context.Context, podList PodListFunc) Handle { - return &eppHandle{ - ctx: ctx, - HandlePlugins: &eppHandlePlugins{ - plugins: map[string]Plugin{}, - }, - podList: podList, + if h.podList == nil { + return nil } + return h.podList() } -// PluginByType retrieves the specified plugin by name and verifies its type -func PluginByType[P Plugin](handlePlugins HandlePlugins, name string) (P, error) { - var zero P +// PluginByType retrieves a Singleton plugin by name and casts it to the expected type T. +func PluginByType[T Plugin](h HandlePlugins, name string) (T, error) { + var zero T - rawPlugin := handlePlugins.Plugin(name) + rawPlugin := h.Plugin(name) if rawPlugin == nil { - return zero, fmt.Errorf("there is no plugin with the name '%s' defined", name) + return zero, fmt.Errorf("plugin %q not found", name) } - plugin, ok := rawPlugin.(P) + + plugin, ok := rawPlugin.(T) if !ok { - return zero, fmt.Errorf("the plugin with the name '%s' is not an instance of %T", name, zero) + return zero, fmt.Errorf("plugin %q is type %T, expected %T", name, rawPlugin, zero) } + return plugin, nil } diff --git a/pkg/epp/plugins/handle_test.go b/pkg/epp/plugins/handle_test.go new file mode 100644 index 000000000..ad8865290 --- /dev/null +++ b/pkg/epp/plugins/handle_test.go @@ -0,0 +1,148 @@ +/* +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 plugins + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" +) + +// mockPluginImpl is a simple struct to verify casting/retrieval. +type mockPluginImpl struct{ name string } + +func (m *mockPluginImpl) TypedName() TypedName { return TypedName{Type: "mock-plugin", Name: m.name} } + +func TestNewEppHandle(t *testing.T) { + t.Parallel() + + ctx := context.Background() + specs := []configapi.PluginSpec{ + {Name: "spec-1", Type: "type-a"}, + } + instance := &mockPluginImpl{name: "instance-1"} + pluginMap := map[string]Plugin{ + "instance-1": instance, + } + + handle := NewEppHandle(ctx, specs, pluginMap, nil) + + assert.Equal(t, ctx, handle.Context(), "handle should return the provided context") + + retrievedSpec := handle.PluginSpec("spec-1") + require.NotNil(t, retrievedSpec, "expected to retrieve configured spec") + assert.Equal(t, "type-a", retrievedSpec.Type, "spec data should match input") + + retrievedPlugin := handle.Plugin("instance-1") + assert.Equal(t, instance, retrievedPlugin, "expected to retrieve configured plugin instance") +} + +func TestEppHandle_Immutability(t *testing.T) { + t.Parallel() + + mutableSpecs := []configapi.PluginSpec{ + {Name: "original", Type: "original-type"}, + } + handle := NewEppHandle(context.Background(), mutableSpecs, nil, nil) + + mutableSpecs[0].Type = "modified-type" + + spec := handle.PluginSpec("original") + require.NotNil(t, spec, "spec should exist") + assert.Equal(t, "original-type", spec.Type, "handle should hold a deep copy of the specs, not a reference") +} + +func TestEppHandle_AddPlugin(t *testing.T) { + t.Parallel() + + ctx := context.Background() + handle := NewEppHandle(ctx, nil, nil, nil) + newPlugin := &mockPluginImpl{name: "dynamic-addition"} + + handle.AddPlugin("dynamic-plugin", newPlugin) + + retrieved := handle.Plugin("dynamic-plugin") + require.NotNil(t, retrieved, "expected to retrieve added plugin") + assert.Equal(t, newPlugin, retrieved, "retrieved plugin should match the added instance") + + all := handle.GetAllPluginsWithNames() + assert.Contains(t, all, "dynamic-plugin") +} + +func TestPluginByType(t *testing.T) { + t.Parallel() + + // We embed mockPluginImpl to automatically satisfy the Plugin interface, but it is a distinct type + // (*wrongPluginImpl != *mockPluginImpl). + type wrongPluginImpl struct { + mockPluginImpl + } + + correctInstance := &mockPluginImpl{name: "correct"} + wrongInstance := &wrongPluginImpl{mockPluginImpl{name: "wrong"}} + + pluginMap := map[string]Plugin{ + "valid": correctInstance, + "invalid": wrongInstance, + } + handle := NewEppHandle(context.Background(), nil, pluginMap, nil) + + tests := []struct { + name string + pluginName string + expectErr bool + errorContains string + }{ + { + name: "Successful Retrieval", + pluginName: "valid", + expectErr: false, + }, + { + name: "Plugin not Found", + pluginName: "missing", + expectErr: true, + errorContains: "not found", + }, + { + name: "Type Mismatch", + pluginName: "invalid", + expectErr: true, + errorContains: "expected *plugins.mockPluginImpl", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := PluginByType[*mockPluginImpl](handle, tc.pluginName) + + if tc.expectErr { + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), tc.errorContains) + } else { + require.NoError(t, err) + assert.Equal(t, correctInstance, result) + } + }) + } +} diff --git a/pkg/epp/plugins/registry.go b/pkg/epp/plugins/registry.go index e9ff0e8fd..bd86bd67e 100644 --- a/pkg/epp/plugins/registry.go +++ b/pkg/epp/plugins/registry.go @@ -18,16 +18,80 @@ package plugins import ( "encoding/json" + "fmt" ) -// Factory is the definition of the factory functions that are used to instantiate plugins -// specified in a configuration. +// FactoryFunc is the function signature for creating a new plugin instance. +// +// Arguments: +// - name: The specific runtime name for this instance (e.g., "tenant-a-queue"). +// - parameters: The JSON configuration block from the blueprint. +// - handle: The EPP handle for accessing global state/other plugins. type FactoryFunc func(name string, parameters json.RawMessage, handle Handle) (Plugin, error) -// Register is a static function that can be called to register plugin factory functions. -func Register(pluginType string, factory FactoryFunc) { - Registry[pluginType] = factory +// PluginLifecycle defines the instantiation policy for a plugin. +type PluginLifecycle int + +const ( + // LifecycleSingleton indicates that a single, shared instance of the plugin should be created at startup. + // This is the default lifecycle for all legacy plugins (Scorers, Global Controllers). + LifecycleSingleton PluginLifecycle = iota + + // LifecycleTransient indicates that the plugin's configuration is a blueprint. + // Independent instances are created at runtime via a PluginFactory. + // + // Usage: Stateful logic scoped to specific runtime entities, such as: + // - Inter-Flow Fairness Policies (scoped to a Priority Band) + // - Intra-Flow Ordering Policies (scoped to a specific Flow/Tenant) + // - Per-Flow Queues + // + // Note: Transient plugins are excluded from the global Request Control DAG. + LifecycleTransient +) + +// PluginRegistration holds the complete, self-describing metadata for a registered plugin type. +type PluginRegistration struct { + Factory FactoryFunc + Lifecycle PluginLifecycle +} + +// Registry is the central, global map of all known plugin types. +// It is populated via init() functions and is read-only during runtime. +var Registry = make(map[string]PluginRegistration) + +// Register makes a plugin available to the system with the default Singleton lifecycle. +// +// This is preserved for backward compatibility. Plugins registered here are assumed +// to be singletons instantiated once at startup. +func Register(pluginImplType string, factory FactoryFunc) { + RegisterWithMetadata(pluginImplType, PluginRegistration{ + Factory: factory, + Lifecycle: LifecycleSingleton, + }) +} + +// RegisterWithMetadata makes a plugin available to the system with explicit lifecycle definitions. +// +// This function must only be called during package initialization (init()). +// Panics if a plugin with the same type is already registered. +func RegisterWithMetadata(pluginImplType string, reg PluginRegistration) { + if _, exists := Registry[pluginImplType]; exists { + panic(fmt.Sprintf("plugin type %q is already registered", pluginImplType)) + } + Registry[pluginImplType] = reg } -// Registry is a mapping from plugin name to Factory function -var Registry map[string]FactoryFunc = map[string]FactoryFunc{} +// ValidatePluginRef checks if a plugin reference is valid based on its type and lifecycle. +// It ensures that the configuration does not attempt to use a Transient plugin where a Singleton is expected. +func ValidatePluginRef(pluginType string, expectedLifecycle PluginLifecycle) error { + reg, ok := Registry[pluginType] + if !ok { + return fmt.Errorf("plugin type %q not found in registry", pluginType) + } + + if reg.Lifecycle != expectedLifecycle { + return fmt.Errorf("plugin type %q has lifecycle %v, but expected %v", pluginType, reg.Lifecycle, expectedLifecycle) + } + + return nil +} diff --git a/pkg/epp/plugins/registry_test.go b/pkg/epp/plugins/registry_test.go new file mode 100644 index 000000000..2c1c18bc0 --- /dev/null +++ b/pkg/epp/plugins/registry_test.go @@ -0,0 +1,162 @@ +/* +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 plugins + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// noOpFactory is a helper for testing registration. +func noOpFactory(name string, _ json.RawMessage, _ Handle) (Plugin, error) { + return nil, nil +} + +func TestRegister(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pluginType string + lifecycle PluginLifecycle + shouldPanic bool + }{ + { + name: "Success - Registers Singleton", + pluginType: "test-singleton-1", + lifecycle: LifecycleSingleton, + }, + { + name: "Success - Registers Transient", + pluginType: "test-transient-1", + lifecycle: LifecycleTransient, + }, + { + name: "Panics - Duplicate Registration", + pluginType: "test-duplicate-1", + lifecycle: LifecycleSingleton, + shouldPanic: true, // We will register it once in setup, then try again. + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + reg := PluginRegistration{ + Factory: noOpFactory, + Lifecycle: tc.lifecycle, + } + + if tc.shouldPanic { + // Pre-register the duplicate. + RegisterWithMetadata(tc.pluginType, reg) + + assert.PanicsWithValue(t, "plugin type \"test-duplicate-1\" is already registered", func() { + RegisterWithMetadata(tc.pluginType, reg) + }, "expected RegisterWithMetadata to panic on duplicate registration") + } else { + assert.NotPanics(t, func() { + RegisterWithMetadata(tc.pluginType, reg) + }, "expected fresh registration to succeed") + + err := ValidatePluginRef(tc.pluginType, tc.lifecycle) + assert.NoError(t, err, "expected plugin to be registered and valid") + } + }) + } +} + +func TestValidatePluginRef(t *testing.T) { + // We must register types globally to test validation. + // We use a unique prefix to ensure this setup doesn't conflict with other tests. + const ( + validSingleton = "val-singleton" + validTransient = "val-transient" + ) + + // Setup Global State (Idempotent for this test file) + // Since we can't unregister, we just ensure we don't panic if it ran before. + defer func() { _ = recover() }() + Register(validSingleton, noOpFactory) + RegisterWithMetadata(validTransient, PluginRegistration{ + Factory: noOpFactory, + Lifecycle: LifecycleTransient, + }) + + t.Parallel() + + tests := []struct { + name string + requestedType string + expectedLifecycle PluginLifecycle + expectError bool + errorContains string + }{ + { + name: "Valid - Singleton Reference", + requestedType: validSingleton, + expectedLifecycle: LifecycleSingleton, + expectError: false, + }, + { + name: "Valid - Transient Reference", + requestedType: validTransient, + expectedLifecycle: LifecycleTransient, + expectError: false, + }, + { + name: "Error - Mismatched Lifecycle Singleton Expected Transient", + requestedType: validSingleton, + expectedLifecycle: LifecycleTransient, + expectError: true, + errorContains: "has lifecycle 0, but expected 1", + }, + { + name: "Error - Mismatched Lifecycle Transient Expected Singleton", + requestedType: validTransient, + expectedLifecycle: LifecycleSingleton, + expectError: true, + errorContains: "has lifecycle 1, but expected 0", + }, + { + name: "Error - Unknown Plugin Type", + requestedType: "non-existent-plugin", + expectedLifecycle: LifecycleSingleton, + expectError: true, + errorContains: "not found in registry", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := ValidatePluginRef(tc.requestedType, tc.expectedLifecycle) + + if tc.expectError { + require.Error(t, err, "expected ValidatePluginRef to fail") + assert.Contains(t, err.Error(), tc.errorContains, "error message should match expected cause") + } else { + assert.NoError(t, err, "expected ValidatePluginRef to succeed") + } + }) + } +} diff --git a/test/utils/handle.go b/test/utils/handle.go index 15dfe10a0..c64b784ca 100644 --- a/test/utils/handle.go +++ b/test/utils/handle.go @@ -21,12 +21,14 @@ import ( "k8s.io/apimachinery/pkg/types" + configapi "sigs.k8s.io/gateway-api-inference-extension/apix/config/v1alpha1" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/plugins" ) -// testHandle is an implmentation of plugins.Handle for test purposes +// testHandle is an implementation of plugins.Handle for test purposes type testHandle struct { - ctx context.Context + ctx context.Context + pluginSpecs map[string]*configapi.PluginSpec plugins.HandlePlugins } @@ -39,6 +41,16 @@ func (h *testHandle) PodList() []types.NamespacedName { return []types.NamespacedName{} } +// PluginSpec returns the spec for the given name +func (h *testHandle) PluginSpec(name string) *configapi.PluginSpec { + return h.pluginSpecs[name] +} + +// AddPluginSpec is a helper to inject specs during tests +func (h *testHandle) AddPluginSpec(spec configapi.PluginSpec) { + h.pluginSpecs[spec.Name] = &spec +} + type testHandlePlugins struct { plugins map[string]plugins.Plugin } @@ -63,9 +75,10 @@ func (h *testHandlePlugins) GetAllPluginsWithNames() map[string]plugins.Plugin { return h.plugins } -func NewTestHandle(ctx context.Context) plugins.Handle { +func NewTestHandle(ctx context.Context) *testHandle { return &testHandle{ - ctx: ctx, + ctx: ctx, + pluginSpecs: map[string]*configapi.PluginSpec{}, HandlePlugins: &testHandlePlugins{ plugins: map[string]plugins.Plugin{}, },