diff --git a/api/v1alpha1/ai_gateway_route.go b/api/v1alpha1/ai_gateway_route.go index 31c5d8eb97..d77f7d1f24 100644 --- a/api/v1alpha1/ai_gateway_route.go +++ b/api/v1alpha1/ai_gateway_route.go @@ -400,6 +400,11 @@ type AIGatewayFilterConfigExternalProcessor struct { // Resources required by the external processor container. // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ // + // Deprecated: Use GatewayConfig for gateway-scoped resource configuration instead. + // Configure resources using GatewayConfig.spec.extProc.resources and reference it + // from the Gateway via the "aigateway.envoyproxy.io/gateway-config" annotation. + // This field will be removed in a future version. + // // Note: when multiple AIGatewayRoute resources are attached to the same Gateway, and each // AIGatewayRoute has a different resource configuration, the ai-gateway will pick one of them // to configure the resource requirements of the external processor container. diff --git a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/api_client.go b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/api_client.go index 01de2671bc..4660496c32 100644 --- a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/api_client.go +++ b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/api_client.go @@ -20,6 +20,7 @@ type AigatewayV1alpha1Interface interface { AIGatewayRoutesGetter AIServiceBackendsGetter BackendSecurityPoliciesGetter + GatewayConfigsGetter MCPRoutesGetter } @@ -40,6 +41,10 @@ func (c *AigatewayV1alpha1Client) BackendSecurityPolicies(namespace string) Back return newBackendSecurityPolicies(c, namespace) } +func (c *AigatewayV1alpha1Client) GatewayConfigs(namespace string) GatewayConfigInterface { + return newGatewayConfigs(c, namespace) +} + func (c *AigatewayV1alpha1Client) MCPRoutes(namespace string) MCPRouteInterface { return newMCPRoutes(c, namespace) } diff --git a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go index df36fc925f..e10c0b3f55 100644 --- a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go +++ b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_api_client.go @@ -29,6 +29,10 @@ func (c *FakeAigatewayV1alpha1) BackendSecurityPolicies(namespace string) v1alph return newFakeBackendSecurityPolicies(c, namespace) } +func (c *FakeAigatewayV1alpha1) GatewayConfigs(namespace string) v1alpha1.GatewayConfigInterface { + return newFakeGatewayConfigs(c, namespace) +} + func (c *FakeAigatewayV1alpha1) MCPRoutes(namespace string) v1alpha1.MCPRouteInterface { return newFakeMCPRoutes(c, namespace) } diff --git a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_gatewayconfig.go b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_gatewayconfig.go new file mode 100644 index 0000000000..68c26941a1 --- /dev/null +++ b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_gatewayconfig.go @@ -0,0 +1,41 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1alpha1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" + apiv1alpha1 "github.com/envoyproxy/ai-gateway/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1" + gentype "k8s.io/client-go/gentype" +) + +// fakeGatewayConfigs implements GatewayConfigInterface +type fakeGatewayConfigs struct { + *gentype.FakeClientWithList[*v1alpha1.GatewayConfig, *v1alpha1.GatewayConfigList] + Fake *FakeAigatewayV1alpha1 +} + +func newFakeGatewayConfigs(fake *FakeAigatewayV1alpha1, namespace string) apiv1alpha1.GatewayConfigInterface { + return &fakeGatewayConfigs{ + gentype.NewFakeClientWithList[*v1alpha1.GatewayConfig, *v1alpha1.GatewayConfigList]( + fake.Fake, + namespace, + v1alpha1.SchemeGroupVersion.WithResource("gatewayconfigs"), + v1alpha1.SchemeGroupVersion.WithKind("GatewayConfig"), + func() *v1alpha1.GatewayConfig { return &v1alpha1.GatewayConfig{} }, + func() *v1alpha1.GatewayConfigList { return &v1alpha1.GatewayConfigList{} }, + func(dst, src *v1alpha1.GatewayConfigList) { dst.ListMeta = src.ListMeta }, + func(list *v1alpha1.GatewayConfigList) []*v1alpha1.GatewayConfig { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1alpha1.GatewayConfigList, items []*v1alpha1.GatewayConfig) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/gatewayconfig.go b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/gatewayconfig.go new file mode 100644 index 0000000000..ed316e9a90 --- /dev/null +++ b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/gatewayconfig.go @@ -0,0 +1,59 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Code generated by client-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + + apiv1alpha1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" + scheme "github.com/envoyproxy/ai-gateway/api/v1alpha1/client/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// GatewayConfigsGetter has a method to return a GatewayConfigInterface. +// A group's client should implement this interface. +type GatewayConfigsGetter interface { + GatewayConfigs(namespace string) GatewayConfigInterface +} + +// GatewayConfigInterface has methods to work with GatewayConfig resources. +type GatewayConfigInterface interface { + Create(ctx context.Context, gatewayConfig *apiv1alpha1.GatewayConfig, opts v1.CreateOptions) (*apiv1alpha1.GatewayConfig, error) + Update(ctx context.Context, gatewayConfig *apiv1alpha1.GatewayConfig, opts v1.UpdateOptions) (*apiv1alpha1.GatewayConfig, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, gatewayConfig *apiv1alpha1.GatewayConfig, opts v1.UpdateOptions) (*apiv1alpha1.GatewayConfig, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.GatewayConfig, error) + List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.GatewayConfigList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.GatewayConfig, err error) + GatewayConfigExpansion +} + +// gatewayConfigs implements GatewayConfigInterface +type gatewayConfigs struct { + *gentype.ClientWithList[*apiv1alpha1.GatewayConfig, *apiv1alpha1.GatewayConfigList] +} + +// newGatewayConfigs returns a GatewayConfigs +func newGatewayConfigs(c *AigatewayV1alpha1Client, namespace string) *gatewayConfigs { + return &gatewayConfigs{ + gentype.NewClientWithList[*apiv1alpha1.GatewayConfig, *apiv1alpha1.GatewayConfigList]( + "gatewayconfigs", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *apiv1alpha1.GatewayConfig { return &apiv1alpha1.GatewayConfig{} }, + func() *apiv1alpha1.GatewayConfigList { return &apiv1alpha1.GatewayConfigList{} }, + ), + } +} diff --git a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/generated_expansion.go b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/generated_expansion.go index bcc29e30e0..f902b3d82a 100644 --- a/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/generated_expansion.go +++ b/api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/generated_expansion.go @@ -13,4 +13,6 @@ type AIServiceBackendExpansion interface{} type BackendSecurityPolicyExpansion interface{} +type GatewayConfigExpansion interface{} + type MCPRouteExpansion interface{} diff --git a/api/v1alpha1/client/informers/externalversions/api/v1alpha1/gatewayconfig.go b/api/v1alpha1/client/informers/externalversions/api/v1alpha1/gatewayconfig.go new file mode 100644 index 0000000000..60a5b50d61 --- /dev/null +++ b/api/v1alpha1/client/informers/externalversions/api/v1alpha1/gatewayconfig.go @@ -0,0 +1,91 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Code generated by informer-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + context "context" + time "time" + + aigatewayapiv1alpha1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" + versioned "github.com/envoyproxy/ai-gateway/api/v1alpha1/client/clientset/versioned" + internalinterfaces "github.com/envoyproxy/ai-gateway/api/v1alpha1/client/informers/externalversions/internalinterfaces" + apiv1alpha1 "github.com/envoyproxy/ai-gateway/api/v1alpha1/client/listers/api/v1alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// GatewayConfigInformer provides access to a shared informer and lister for +// GatewayConfigs. +type GatewayConfigInformer interface { + Informer() cache.SharedIndexInformer + Lister() apiv1alpha1.GatewayConfigLister +} + +type gatewayConfigInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewGatewayConfigInformer constructs a new informer for GatewayConfig type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewGatewayConfigInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredGatewayConfigInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredGatewayConfigInformer constructs a new informer for GatewayConfig type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredGatewayConfigInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AigatewayV1alpha1().GatewayConfigs(namespace).List(context.Background(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AigatewayV1alpha1().GatewayConfigs(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AigatewayV1alpha1().GatewayConfigs(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.AigatewayV1alpha1().GatewayConfigs(namespace).Watch(ctx, options) + }, + }, + &aigatewayapiv1alpha1.GatewayConfig{}, + resyncPeriod, + indexers, + ) +} + +func (f *gatewayConfigInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredGatewayConfigInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *gatewayConfigInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&aigatewayapiv1alpha1.GatewayConfig{}, f.defaultInformer) +} + +func (f *gatewayConfigInformer) Lister() apiv1alpha1.GatewayConfigLister { + return apiv1alpha1.NewGatewayConfigLister(f.Informer().GetIndexer()) +} diff --git a/api/v1alpha1/client/informers/externalversions/api/v1alpha1/interface.go b/api/v1alpha1/client/informers/externalversions/api/v1alpha1/interface.go index f7abb5a770..d6ee5f2774 100644 --- a/api/v1alpha1/client/informers/externalversions/api/v1alpha1/interface.go +++ b/api/v1alpha1/client/informers/externalversions/api/v1alpha1/interface.go @@ -19,6 +19,8 @@ type Interface interface { AIServiceBackends() AIServiceBackendInformer // BackendSecurityPolicies returns a BackendSecurityPolicyInformer. BackendSecurityPolicies() BackendSecurityPolicyInformer + // GatewayConfigs returns a GatewayConfigInformer. + GatewayConfigs() GatewayConfigInformer // MCPRoutes returns a MCPRouteInformer. MCPRoutes() MCPRouteInformer } @@ -49,6 +51,11 @@ func (v *version) BackendSecurityPolicies() BackendSecurityPolicyInformer { return &backendSecurityPolicyInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// GatewayConfigs returns a GatewayConfigInformer. +func (v *version) GatewayConfigs() GatewayConfigInformer { + return &gatewayConfigInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // MCPRoutes returns a MCPRouteInformer. func (v *version) MCPRoutes() MCPRouteInformer { return &mCPRouteInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/api/v1alpha1/client/informers/externalversions/generic.go b/api/v1alpha1/client/informers/externalversions/generic.go index c9fbac3a58..f9a9112304 100644 --- a/api/v1alpha1/client/informers/externalversions/generic.go +++ b/api/v1alpha1/client/informers/externalversions/generic.go @@ -48,6 +48,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Aigateway().V1alpha1().AIServiceBackends().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("backendsecuritypolicies"): return &genericInformer{resource: resource.GroupResource(), informer: f.Aigateway().V1alpha1().BackendSecurityPolicies().Informer()}, nil + case v1alpha1.SchemeGroupVersion.WithResource("gatewayconfigs"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Aigateway().V1alpha1().GatewayConfigs().Informer()}, nil case v1alpha1.SchemeGroupVersion.WithResource("mcproutes"): return &genericInformer{resource: resource.GroupResource(), informer: f.Aigateway().V1alpha1().MCPRoutes().Informer()}, nil diff --git a/api/v1alpha1/client/listers/api/v1alpha1/expansion_generated.go b/api/v1alpha1/client/listers/api/v1alpha1/expansion_generated.go index ccfde892c6..770e36d5cc 100644 --- a/api/v1alpha1/client/listers/api/v1alpha1/expansion_generated.go +++ b/api/v1alpha1/client/listers/api/v1alpha1/expansion_generated.go @@ -31,6 +31,14 @@ type BackendSecurityPolicyListerExpansion interface{} // BackendSecurityPolicyNamespaceLister. type BackendSecurityPolicyNamespaceListerExpansion interface{} +// GatewayConfigListerExpansion allows custom methods to be added to +// GatewayConfigLister. +type GatewayConfigListerExpansion interface{} + +// GatewayConfigNamespaceListerExpansion allows custom methods to be added to +// GatewayConfigNamespaceLister. +type GatewayConfigNamespaceListerExpansion interface{} + // MCPRouteListerExpansion allows custom methods to be added to // MCPRouteLister. type MCPRouteListerExpansion interface{} diff --git a/api/v1alpha1/client/listers/api/v1alpha1/gatewayconfig.go b/api/v1alpha1/client/listers/api/v1alpha1/gatewayconfig.go new file mode 100644 index 0000000000..3ea744a613 --- /dev/null +++ b/api/v1alpha1/client/listers/api/v1alpha1/gatewayconfig.go @@ -0,0 +1,59 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +// Code generated by lister-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + apiv1alpha1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// GatewayConfigLister helps list GatewayConfigs. +// All objects returned here must be treated as read-only. +type GatewayConfigLister interface { + // List lists all GatewayConfigs in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.GatewayConfig, err error) + // GatewayConfigs returns an object that can list and get GatewayConfigs. + GatewayConfigs(namespace string) GatewayConfigNamespaceLister + GatewayConfigListerExpansion +} + +// gatewayConfigLister implements the GatewayConfigLister interface. +type gatewayConfigLister struct { + listers.ResourceIndexer[*apiv1alpha1.GatewayConfig] +} + +// NewGatewayConfigLister returns a new GatewayConfigLister. +func NewGatewayConfigLister(indexer cache.Indexer) GatewayConfigLister { + return &gatewayConfigLister{listers.New[*apiv1alpha1.GatewayConfig](indexer, apiv1alpha1.Resource("gatewayconfig"))} +} + +// GatewayConfigs returns an object that can list and get GatewayConfigs. +func (s *gatewayConfigLister) GatewayConfigs(namespace string) GatewayConfigNamespaceLister { + return gatewayConfigNamespaceLister{listers.NewNamespaced[*apiv1alpha1.GatewayConfig](s.ResourceIndexer, namespace)} +} + +// GatewayConfigNamespaceLister helps list and get GatewayConfigs. +// All objects returned here must be treated as read-only. +type GatewayConfigNamespaceLister interface { + // List lists all GatewayConfigs in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*apiv1alpha1.GatewayConfig, err error) + // Get retrieves the GatewayConfig from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*apiv1alpha1.GatewayConfig, error) + GatewayConfigNamespaceListerExpansion +} + +// gatewayConfigNamespaceLister implements the GatewayConfigNamespaceLister +// interface. +type gatewayConfigNamespaceLister struct { + listers.ResourceIndexer[*apiv1alpha1.GatewayConfig] +} diff --git a/api/v1alpha1/gateway_config.go b/api/v1alpha1/gateway_config.go new file mode 100644 index 0000000000..d702e53986 --- /dev/null +++ b/api/v1alpha1/gateway_config.go @@ -0,0 +1,98 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GatewayConfig provides configuration for the AI Gateway external processor +// container that is deployed alongside the Gateway. +// +// A GatewayConfig is referenced by a Gateway via the annotation +// "aigateway.envoyproxy.io/gateway-config". The GatewayConfig must be in the +// same namespace as the Gateway that references it. +// +// This allows gateway-level configuration of the external processor, including +// environment variables (e.g., for tracing configuration) and resource requirements. +// +// Multiple Gateways can reference the same GatewayConfig to share configuration. +// +// Environment Variable Precedence: +// When merging environment variables, the following precedence applies (highest to lowest): +// 1. GatewayConfig.Spec.ExtProc.Env (this resource) +// 2. Global controller flags (extProcExtraEnvVars) +// +// If the same environment variable name exists in both sources, the GatewayConfig +// value takes precedence. +// +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:shortName=gwconfig +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[-1:].type` +type GatewayConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + // Spec defines the configuration for the external processor. + Spec GatewayConfigSpec `json:"spec,omitempty"` + // Status defines the status of the GatewayConfig. + Status GatewayConfigStatus `json:"status,omitempty"` +} + +// GatewayConfigList contains a list of GatewayConfig. +// +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:object:root=true +type GatewayConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []GatewayConfig `json:"items"` +} + +// GatewayConfigSpec defines the configuration for the AI Gateway. +type GatewayConfigSpec struct { + // ExtProc defines the configuration for the external processor container. + // + // +optional + ExtProc *GatewayConfigExtProc `json:"extProc,omitempty"` +} + +// GatewayConfigExtProc defines the configuration for the external processor container. +type GatewayConfigExtProc struct { + // Env specifies environment variables to be added to the external processor container. + // These variables are merged with any global environment variables configured in the + // AI Gateway controller. If there are conflicting variable names, the values defined + // here take precedence over global values. + // + // Common use cases include: + // - OTEL tracing configuration (e.g., OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT) + // + // +optional + // +kubebuilder:validation:MaxItems=32 + Env []corev1.EnvVar `json:"env,omitempty"` + + // Resources specifies the compute resources required by the external processor container. + // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + // + // If not specified, the external processor will use Kubernetes default resource allocations. + // + // +optional + Resources *corev1.ResourceRequirements `json:"resources,omitempty"` +} + +// GatewayConfigStatus defines the observed state of GatewayConfig. +type GatewayConfigStatus struct { + // Conditions describe the current conditions of the GatewayConfig. + // + // +optional + // +listType=map + // +listMapKey=type + // +kubebuilder:validation:MaxItems=8 + Conditions []metav1.Condition `json:"conditions,omitempty"` +} diff --git a/api/v1alpha1/registry.go b/api/v1alpha1/registry.go index b98a367fed..9ec0bd7da7 100644 --- a/api/v1alpha1/registry.go +++ b/api/v1alpha1/registry.go @@ -17,6 +17,7 @@ func init() { SchemeBuilder.Register(&AIServiceBackend{}, &AIServiceBackendList{}) SchemeBuilder.Register(&BackendSecurityPolicy{}, &BackendSecurityPolicyList{}) SchemeBuilder.Register(&MCPRoute{}, &MCPRouteList{}) + SchemeBuilder.Register(&GatewayConfig{}, &GatewayConfigList{}) } const GroupName = "aigateway.envoyproxy.io" @@ -48,6 +49,8 @@ func AddKnownTypes(scheme *runtime.Scheme) error { &BackendSecurityPolicyList{}, &MCPRoute{}, &MCPRouteList{}, + &GatewayConfig{}, + &GatewayConfigList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d2d42ae93a..6142f455f4 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -825,6 +825,134 @@ func (in *GCPWorkloadIdentityProvider) DeepCopy() *GCPWorkloadIdentityProvider { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayConfig) DeepCopyInto(out *GatewayConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayConfig. +func (in *GatewayConfig) DeepCopy() *GatewayConfig { + if in == nil { + return nil + } + out := new(GatewayConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GatewayConfig) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayConfigExtProc) DeepCopyInto(out *GatewayConfigExtProc) { + *out = *in + if in.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]corev1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = new(corev1.ResourceRequirements) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayConfigExtProc. +func (in *GatewayConfigExtProc) DeepCopy() *GatewayConfigExtProc { + if in == nil { + return nil + } + out := new(GatewayConfigExtProc) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayConfigList) DeepCopyInto(out *GatewayConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]GatewayConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayConfigList. +func (in *GatewayConfigList) DeepCopy() *GatewayConfigList { + if in == nil { + return nil + } + out := new(GatewayConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *GatewayConfigList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayConfigSpec) DeepCopyInto(out *GatewayConfigSpec) { + *out = *in + if in.ExtProc != nil { + in, out := &in.ExtProc, &out.ExtProc + *out = new(GatewayConfigExtProc) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayConfigSpec. +func (in *GatewayConfigSpec) DeepCopy() *GatewayConfigSpec { + if in == nil { + return nil + } + out := new(GatewayConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GatewayConfigStatus) DeepCopyInto(out *GatewayConfigStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GatewayConfigStatus. +func (in *GatewayConfigStatus) DeepCopy() *GatewayConfigStatus { + if in == nil { + return nil + } + out := new(GatewayConfigStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HTTPBodyField) DeepCopyInto(out *HTTPBodyField) { *out = *in diff --git a/cmd/aigw/translate.go b/cmd/aigw/translate.go index d5da63c9ae..57f7a9a47e 100644 --- a/cmd/aigw/translate.go +++ b/cmd/aigw/translate.go @@ -168,6 +168,11 @@ func collectObjects(yamlInput string, out io.Writer, logger *slog.Logger) ( // need to reconcile them; just create them as-is. mustExtractAndAppend(obj, &backendTLSConfigs) mustWriteObj(nil, obj, out) + case "GatewayConfig": + // GatewayConfig is gateway-scoped configuration for extproc containers. + // Write it back as-is to the output. + logger.Info("Writing GatewayConfig to output as-is", "name", obj.GetName()) + mustWriteObj(nil, obj, out) default: // Now you can inspect or manipulate the CRD. logger.Info("Writing back non-target object into the output as-is", "kind", obj.GetKind(), "name", obj.GetName()) diff --git a/examples/gateway-config/comprehensive.yaml b/examples/gateway-config/comprehensive.yaml new file mode 100644 index 0000000000..b29f69097e --- /dev/null +++ b/examples/gateway-config/comprehensive.yaml @@ -0,0 +1,64 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: GatewayConfig +metadata: + name: comprehensive-config + namespace: default +spec: + extProc: + env: + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://otel-collector.monitoring:4317" + - name: OTEL_EXPORTER_OTLP_HEADERS + value: "api-key=your-secret-key" + - name: OTEL_SERVICE_NAME + value: "ai-gateway-production" + - name: OTEL_TRACES_SAMPLER + value: "parentbased_traceidratio" + - name: OTEL_TRACES_SAMPLER_ARG + value: "0.1" + - name: LOG_LEVEL + value: "info" + - name: ENABLE_DEBUG_METRICS + value: "false" + + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: production-ai-gateway + namespace: default + annotations: + aigateway.envoyproxy.io/gateway-config: comprehensive-config +spec: + gatewayClassName: envoy-gateway + listeners: + - name: http + protocol: HTTP + port: 8080 + +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: staging-ai-gateway + namespace: default + annotations: + aigateway.envoyproxy.io/gateway-config: comprehensive-config +spec: + gatewayClassName: envoy-gateway + listeners: + - name: http + protocol: HTTP + port: 8081 diff --git a/internal/controller/ai_gateway_route.go b/internal/controller/ai_gateway_route.go index a29d855af5..f19c8d354a 100644 --- a/internal/controller/ai_gateway_route.go +++ b/internal/controller/ai_gateway_route.go @@ -43,6 +43,9 @@ const ( egOwningGatewayNamespaceLabel = egAnnotationPrefix + "owning-gateway-namespace" // apiKeyInSecret is the key to store OpenAI API key. apiKeyInSecret = "apiKey" + // GatewayConfigAnnotationKey is the annotation key used on Gateway objects to reference a GatewayConfig. + // The value should be the name of the GatewayConfig resource in the same namespace as the Gateway. + GatewayConfigAnnotationKey = "aigateway.envoyproxy.io/gateway-config" ) // AIGatewayRouteController implements [reconcile.TypedReconciler]. diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 29de46da90..47bc25d48d 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -211,6 +211,13 @@ func StartControllers(ctx context.Context, mgr manager.Manager, config *rest.Con return fmt.Errorf("failed to create controller for MCPRoute: %w", err) } + // GatewayConfig controller for gateway-scoped configuration. + gatewayConfigC := NewGatewayConfigController(c, logger.WithName("gateway-config"), gatewayEventChan) + if err = TypedControllerBuilderForCRD(mgr, &aigv1a1.GatewayConfig{}). + Complete(gatewayConfigC); err != nil { + return fmt.Errorf("failed to create controller for GatewayConfig: %w", err) + } + // ReferenceGrant controller for cross-namespace access validation referenceGrantC := NewReferenceGrantController(c, logger.WithName("reference-grant"), aiGatewayRouteEventChan) if err = TypedControllerBuilderForCRD(mgr, &gwapiv1b1.ReferenceGrant{}). @@ -274,6 +281,8 @@ const ( // k8sClientIndexAIServiceBackendToTargetingBackendSecurityPolicy is the index name that maps from an AIServiceBackend // to the BackendSecurityPolicy whose targetRefs contains the AIServiceBackend. k8sClientIndexAIServiceBackendToTargetingBackendSecurityPolicy = "AIServiceBackendToTargetingBackendSecurityPolicy" + // k8sClientIndexGatewayToGatewayConfig maps from a GatewayConfig name to Gateways referencing it. + k8sClientIndexGatewayToGatewayConfig = "GatewayToGatewayConfig" // Indexes for MCP Gateway // @@ -305,6 +314,12 @@ func ApplyIndexing(ctx context.Context, indexer func(ctx context.Context, obj cl return fmt.Errorf("failed to index field for BackendSecurityPolicy targetRefs: %w", err) } + err = indexer(ctx, &gwapiv1.Gateway{}, + k8sClientIndexGatewayToGatewayConfig, gatewayToGatewayConfigIndexFunc) + if err != nil { + return fmt.Errorf("failed to create index from GatewayConfig to Gateway: %w", err) + } + // Apply indexes to MCP Gateways. err = indexer(ctx, &aigv1a1.MCPRoute{}, k8sClientIndexMCPRouteToAttachedGateway, mcpRouteToAttachedGatewayIndexFunc) @@ -328,6 +343,18 @@ func mcpRouteToAttachedGatewayIndexFunc(o client.Object) []string { return ret } +func gatewayToGatewayConfigIndexFunc(o client.Object) []string { + gateway := o.(*gwapiv1.Gateway) + if gateway.Annotations == nil { + return nil + } + configName, ok := gateway.Annotations[GatewayConfigAnnotationKey] + if !ok || configName == "" { + return nil + } + return []string{configName} +} + func aiGatewayRouteToAttachedGatewayIndexFunc(o client.Object) []string { aiGatewayRoute := o.(*aigv1a1.AIGatewayRoute) var ret []string diff --git a/internal/controller/gateway_config.go b/internal/controller/gateway_config.go new file mode 100644 index 0000000000..23cbc56351 --- /dev/null +++ b/internal/controller/gateway_config.go @@ -0,0 +1,155 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package controller + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" +) + +// GatewayConfigController implements [reconcile.TypedReconciler] for [aigv1a1.GatewayConfig]. +// +// This handles the GatewayConfig resource and notifies referencing Gateways of changes. +// +// Exported for testing purposes. +type GatewayConfigController struct { + client client.Client + logger logr.Logger + // gatewayEventChan is a channel to send events to the gateway controller. + gatewayEventChan chan event.GenericEvent +} + +// NewGatewayConfigController creates a new reconcile.TypedReconciler[reconcile.Request] for the GatewayConfig resource. +func NewGatewayConfigController( + client client.Client, + logger logr.Logger, + gatewayEventChan chan event.GenericEvent, +) *GatewayConfigController { + return &GatewayConfigController{ + client: client, + logger: logger, + gatewayEventChan: gatewayEventChan, + } +} + +// Reconcile implements [reconcile.TypedReconciler] for [aigv1a1.GatewayConfig]. +func (c *GatewayConfigController) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) { + c.logger.Info("Reconciling GatewayConfig", "namespace", req.Namespace, "name", req.Name) + + var gatewayConfig aigv1a1.GatewayConfig + if err := c.client.Get(ctx, req.NamespacedName, &gatewayConfig); err != nil { + if apierrors.IsNotFound(err) { + c.logger.Info("Deleting GatewayConfig", "namespace", req.Namespace, "name", req.Name) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if err := c.syncGatewayConfig(ctx, &gatewayConfig); err != nil { + c.logger.Error(err, "failed to sync GatewayConfig") + c.updateGatewayConfigStatus(ctx, &gatewayConfig, aigv1a1.ConditionTypeNotAccepted, err.Error()) + return ctrl.Result{}, err + } + c.updateGatewayConfigStatus(ctx, &gatewayConfig, aigv1a1.ConditionTypeAccepted, "GatewayConfig reconciled successfully") + return reconcile.Result{}, nil +} + +// syncGatewayConfig is the main logic for reconciling the GatewayConfig resource. +func (c *GatewayConfigController) syncGatewayConfig(ctx context.Context, gatewayConfig *aigv1a1.GatewayConfig) error { + // Find all Gateways that reference this GatewayConfig. + referencingGateways, err := c.findReferencingGateways(ctx, gatewayConfig) + if err != nil { + return fmt.Errorf("failed to find referencing Gateways: %w", err) + } + + if gatewayConfig.DeletionTimestamp != nil { + c.notifyReferencingGateways(gatewayConfig, referencingGateways) + return nil + } + + // Notify all referencing Gateways to reconcile. + c.notifyReferencingGateways(gatewayConfig, referencingGateways) + + return nil +} + +// findReferencingGateways finds all Gateways in the same namespace that reference this GatewayConfig. +func (c *GatewayConfigController) findReferencingGateways(ctx context.Context, gatewayConfig *aigv1a1.GatewayConfig) ([]*gwapiv1.Gateway, error) { + var gateways gwapiv1.GatewayList + if err := c.client.List( + ctx, + &gateways, + client.InNamespace(gatewayConfig.Namespace), + client.MatchingFields{k8sClientIndexGatewayToGatewayConfig: gatewayConfig.Name}, + ); err != nil { + return nil, fmt.Errorf("failed to list Gateways: %w", err) + } + + referencingGateways := make([]*gwapiv1.Gateway, 0, len(gateways.Items)) + for i := range gateways.Items { + gw := &gateways.Items[i] + referencingGateways = append(referencingGateways, gw) + } + + return referencingGateways, nil +} + +func (c *GatewayConfigController) notifyReferencingGateways(gatewayConfig *aigv1a1.GatewayConfig, referencingGateways []*gwapiv1.Gateway) { + for _, gw := range referencingGateways { + c.logger.Info("Notifying Gateway of GatewayConfig change", + "gateway_namespace", gw.Namespace, "gateway_name", gw.Name, + "gatewayconfig_name", gatewayConfig.Name) + c.gatewayEventChan <- event.GenericEvent{Object: gw} + } +} + +// updateGatewayConfigStatus updates the status of the GatewayConfig. +func (c *GatewayConfigController) updateGatewayConfigStatus(ctx context.Context, gatewayConfig *aigv1a1.GatewayConfig, conditionType string, message string) { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := c.client.Get(ctx, client.ObjectKey{Name: gatewayConfig.Name, Namespace: gatewayConfig.Namespace}, gatewayConfig); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + + gatewayConfig.Status.Conditions = gatewayConfigConditions(conditionType, message) + return c.client.Status().Update(ctx, gatewayConfig) + }) + if err != nil { + c.logger.Error(err, "failed to update GatewayConfig status") + } +} + +// gatewayConfigConditions creates new conditions for the GatewayConfig status. +func gatewayConfigConditions(conditionType string, message string) []metav1.Condition { + status := metav1.ConditionTrue + if conditionType == aigv1a1.ConditionTypeNotAccepted { + status = metav1.ConditionFalse + } + + return []metav1.Condition{ + { + Type: conditionType, + Status: status, + Reason: conditionType, + Message: message, + LastTransitionTime: metav1.Now(), + }, + } +} diff --git a/internal/controller/gateway_config_test.go b/internal/controller/gateway_config_test.go new file mode 100644 index 0000000000..2274dc5a45 --- /dev/null +++ b/internal/controller/gateway_config_test.go @@ -0,0 +1,262 @@ +// Copyright Envoy AI Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package controller + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + + aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" + internaltesting "github.com/envoyproxy/ai-gateway/internal/testing" +) + +// requireNewFakeClientForGatewayConfig creates a fake client for GatewayConfig tests. +func requireNewFakeClientForGatewayConfig(t *testing.T) client.Client { + t.Helper() + builder := fake.NewClientBuilder().WithScheme(Scheme). + WithStatusSubresource(&aigv1a1.GatewayConfig{}). + WithIndex(&gwapiv1.Gateway{}, k8sClientIndexGatewayToGatewayConfig, gatewayToGatewayConfigIndexFunc) + return builder.Build() +} + +func TestGatewayConfigController_Reconcile(t *testing.T) { + fakeClient := requireNewFakeClientForGatewayConfig(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) + + // Create a GatewayConfig. + gatewayConfig := &aigv1a1.GatewayConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: aigv1a1.GatewayConfigSpec{ + ExtProc: &aigv1a1.GatewayConfigExtProc{ + Env: []corev1.EnvVar{ + {Name: "OTEL_EXPORTER_OTLP_ENDPOINT", Value: "http://otel-collector:4317"}, + }, + Resources: &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("128Mi"), + }, + }, + }, + }, + } + err := fakeClient.Create(t.Context(), gatewayConfig) + require.NoError(t, err) + + // Reconcile - should succeed with no referencing Gateways. + result, err := c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, + }) + require.NoError(t, err) + require.Equal(t, ctrl.Result{}, result) + + // Verify status was updated to Accepted. + var updated aigv1a1.GatewayConfig + err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updated) + require.NoError(t, err) + require.Len(t, updated.Status.Conditions, 1) + require.Equal(t, aigv1a1.ConditionTypeAccepted, updated.Status.Conditions[0].Type) +} + +func TestGatewayConfigController_NotifyGateways(t *testing.T) { + fakeClient := requireNewFakeClientForGatewayConfig(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) + + // Create a GatewayConfig. + gatewayConfig := &aigv1a1.GatewayConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + }, + Spec: aigv1a1.GatewayConfigSpec{ + ExtProc: &aigv1a1.GatewayConfigExtProc{ + Env: []corev1.EnvVar{ + {Name: "LOG_LEVEL", Value: "debug"}, + }, + }, + }, + } + err := fakeClient.Create(t.Context(), gatewayConfig) + require.NoError(t, err) + + // Reconcile without any referencing Gateway - should not add finalizer. + _, err = c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, + }) + require.NoError(t, err) + + var updatedConfig aigv1a1.GatewayConfig + err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) + require.NoError(t, err) + require.Empty(t, updatedConfig.Finalizers) + + // Create a Gateway that references the GatewayConfig. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + Annotations: map[string]string{ + GatewayConfigAnnotationKey: "test-config", + }, + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + Listeners: []gwapiv1.Listener{ + { + Name: "http", + Port: 8080, + Protocol: gwapiv1.HTTPProtocolType, + }, + }, + }, + } + err = fakeClient.Create(t.Context(), gateway) + require.NoError(t, err) + + // Reconcile again - should notify the Gateway and still not add any finalizer. + _, err = c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, + }) + require.NoError(t, err) + + err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) + require.NoError(t, err) + require.Empty(t, updatedConfig.Finalizers) + + // Gateway event should be sent. + events := eventCh.RequireItemsEventually(t, 1) + require.Len(t, events, 1) +} + +func TestGatewayConfigController_MultipleGatewaysReferencing(t *testing.T) { + fakeClient := requireNewFakeClientForGatewayConfig(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) + + // Create a GatewayConfig. + gatewayConfig := &aigv1a1.GatewayConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "shared-config", + Namespace: "default", + }, + Spec: aigv1a1.GatewayConfigSpec{ + ExtProc: &aigv1a1.GatewayConfigExtProc{ + Env: []corev1.EnvVar{ + {Name: "SHARED_VAR", Value: "shared-value"}, + }, + }, + }, + } + err := fakeClient.Create(t.Context(), gatewayConfig) + require.NoError(t, err) + + // Create two Gateways that reference the same GatewayConfig. + for _, name := range []string{"gateway-1", "gateway-2"} { + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + Annotations: map[string]string{ + GatewayConfigAnnotationKey: "shared-config", + }, + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + Listeners: []gwapiv1.Listener{ + { + Name: "http", + Port: 8080, + Protocol: gwapiv1.HTTPProtocolType, + }, + }, + }, + } + err = fakeClient.Create(t.Context(), gateway) + require.NoError(t, err) + } + + // Reconcile - should notify both gateways. + _, err = c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "shared-config", Namespace: "default"}, + }) + require.NoError(t, err) + + var updatedConfig aigv1a1.GatewayConfig + err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "shared-config", Namespace: "default"}, &updatedConfig) + require.NoError(t, err) + require.Empty(t, updatedConfig.Finalizers) + + // Both Gateways should have been notified. + events := eventCh.RequireItemsEventually(t, 2) + require.Len(t, events, 2) +} + +func TestGatewayConfigController_DeletionDoesNotBlock(t *testing.T) { + fakeClient := requireNewFakeClientForGatewayConfig(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) + + deletionTime := metav1.NewTime(time.Now()) + + // Create a GatewayConfig marked for deletion. + gatewayConfig := &aigv1a1.GatewayConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + DeletionTimestamp: &deletionTime, + }, + Spec: aigv1a1.GatewayConfigSpec{}, + } + err := fakeClient.Create(t.Context(), gatewayConfig) + require.NoError(t, err) + + // Create a Gateway that references the GatewayConfig. + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + Annotations: map[string]string{ + GatewayConfigAnnotationKey: "test-config", + }, + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "test-class", + Listeners: []gwapiv1.Listener{ + { + Name: "http", + Port: 8080, + Protocol: gwapiv1.HTTPProtocolType, + }, + }, + }, + } + err = fakeClient.Create(t.Context(), gateway) + require.NoError(t, err) + + // Reconcile should not block deletion and should notify the referencing Gateway. + _, err = c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, + }) + require.NoError(t, err) + + events := eventCh.RequireItemsEventually(t, 1) + require.Len(t, events, 1) +} diff --git a/internal/controller/gateway_mutator.go b/internal/controller/gateway_mutator.go index e164862579..d903598037 100644 --- a/internal/controller/gateway_mutator.go +++ b/internal/controller/gateway_mutator.go @@ -22,6 +22,7 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" aigv1a1 "github.com/envoyproxy/ai-gateway/api/v1alpha1" "github.com/envoyproxy/ai-gateway/internal/internalapi" @@ -273,6 +274,8 @@ func (g *gatewayMutator) mutatePod(ctx context.Context, pod *corev1.Pod, gateway return fmt.Errorf("failed to get filter config secret: %w", err) } + gatewayConfig := g.fetchGatewayConfig(ctx, gatewayName, gatewayNamespace) + // Now we construct the AI Gateway managed containers and volumes. filterConfigSecretName := FilterConfigSecretPerGatewayName(gatewayName, gatewayNamespace) filterConfigVolumeName := mutationNamePrefix + filterConfigSecretName @@ -297,16 +300,17 @@ func (g *gatewayMutator) mutatePod(ctx context.Context, pod *corev1.Pod, gateway podspec.ImagePullSecrets = append(podspec.ImagePullSecrets, g.extProcImagePullSecrets...) } - // Currently, we have to set the resources for the extproc container at route level. - // We choose one of the routes to set the resources for the extproc container. + // Prefer GatewayConfig resources; otherwise leave empty. var resources corev1.ResourceRequirements - for i := range routes.Items { - fc := routes.Items[i].Spec.FilterConfig - if fc != nil && fc.ExternalProcessor != nil && fc.ExternalProcessor.Resources != nil { - resources = *fc.ExternalProcessor.Resources - } + if gatewayConfig != nil && gatewayConfig.Spec.ExtProc != nil && gatewayConfig.Spec.ExtProc.Resources != nil { + resources = *gatewayConfig.Spec.ExtProc.Resources + g.logger.Info("using resources from GatewayConfig", + "gateway_name", gatewayName, "gatewayconfig_name", gatewayConfig.Name) } - envVars := g.extProcExtraEnvVars + + // Merge env vars with GatewayConfig overriding global. + envVars := g.mergeEnvVars(gatewayConfig) + const ( extProcAdminPort = 1064 filterConfigMountPath = "/etc/filter-config" @@ -389,3 +393,68 @@ func (g *gatewayMutator) mutatePod(ctx context.Context, pod *corev1.Pod, gateway } return nil } + +// fetchGatewayConfig returns the referenced GatewayConfig (if present). +func (g *gatewayMutator) fetchGatewayConfig(ctx context.Context, gatewayName, gatewayNamespace string) *aigv1a1.GatewayConfig { + // Fetch the Gateway object. + var gateway gwapiv1.Gateway + if err := g.c.Get(ctx, client.ObjectKey{Name: gatewayName, Namespace: gatewayNamespace}, &gateway); err != nil { + if apierrors.IsNotFound(err) { + g.logger.Info("Gateway not found, using global default configuration", + "gateway_name", gatewayName, "gateway_namespace", gatewayNamespace) + } else { + g.logger.Error(err, "failed to get Gateway, using global default configuration", + "gateway_name", gatewayName, "gateway_namespace", gatewayNamespace) + } + return nil + } + + configName, ok := gateway.Annotations[GatewayConfigAnnotationKey] + if !ok || configName == "" { + return nil + } + + // Fetch the GatewayConfig (must be in same namespace as Gateway). + var gatewayConfig aigv1a1.GatewayConfig + if err := g.c.Get(ctx, client.ObjectKey{Name: configName, Namespace: gatewayNamespace}, &gatewayConfig); err != nil { + if apierrors.IsNotFound(err) { + g.logger.Info("GatewayConfig referenced by Gateway not found, using global defaults", + "gateway_name", gatewayName, "gatewayconfig_name", configName) + } else { + g.logger.Error(err, "failed to get GatewayConfig, using global defaults", + "gateway_name", gatewayName, "gatewayconfig_name", configName) + } + return nil + } + + g.logger.Info("found GatewayConfig for Gateway", + "gateway_name", gatewayName, "gatewayconfig_name", configName) + return &gatewayConfig +} + +// mergeEnvVars merges env vars; GatewayConfig overrides global while preserving order. +func (g *gatewayMutator) mergeEnvVars(gatewayConfig *aigv1a1.GatewayConfig) []corev1.EnvVar { + result := make([]corev1.EnvVar, 0, len(g.extProcExtraEnvVars)) + index := make(map[string]int, len(g.extProcExtraEnvVars)) + + // Add global env vars first (lowest precedence) preserving input order. + for _, env := range g.extProcExtraEnvVars { + result = append(result, env) + index[env.Name] = len(result) - 1 + } + + // Add GatewayConfig env vars (highest precedence) overriding in-place when names collide, + // otherwise append in the order they are defined. + if gatewayConfig != nil && gatewayConfig.Spec.ExtProc != nil { + for _, env := range gatewayConfig.Spec.ExtProc.Env { + if i, ok := index[env.Name]; ok { + result[i] = env + } else { + result = append(result, env) + index[env.Name] = len(result) - 1 + } + } + } + + return result +} diff --git a/internal/controller/gateway_mutator_test.go b/internal/controller/gateway_mutator_test.go index 5481df5497..86aebba7c9 100644 --- a/internal/controller/gateway_mutator_test.go +++ b/internal/controller/gateway_mutator_test.go @@ -477,3 +477,95 @@ func TestParseImagePullSecrets(t *testing.T) { }) } } + +func TestGatewayMutator_mergeEnvVars(t *testing.T) { + tests := []struct { + name string + globalEnvVars string + gatewayConfig *aigv1a1.GatewayConfig + expectedEnvs map[string]string + }{ + { + name: "global env vars only", + globalEnvVars: "GLOBAL_VAR=global-value;LOG_LEVEL=info", + gatewayConfig: nil, + expectedEnvs: map[string]string{ + "GLOBAL_VAR": "global-value", + "LOG_LEVEL": "info", + }, + }, + { + name: "GatewayConfig env vars only", + globalEnvVars: "", + gatewayConfig: &aigv1a1.GatewayConfig{ + Spec: aigv1a1.GatewayConfigSpec{ + ExtProc: &aigv1a1.GatewayConfigExtProc{ + Env: []corev1.EnvVar{ + {Name: "CONFIG_VAR", Value: "config-value"}, + {Name: "LOG_LEVEL", Value: "debug"}, + }, + }, + }, + }, + expectedEnvs: map[string]string{ + "CONFIG_VAR": "config-value", + "LOG_LEVEL": "debug", + }, + }, + { + name: "GatewayConfig overrides global", + globalEnvVars: "LOG_LEVEL=info;GLOBAL_ONLY=global", + gatewayConfig: &aigv1a1.GatewayConfig{ + Spec: aigv1a1.GatewayConfigSpec{ + ExtProc: &aigv1a1.GatewayConfigExtProc{ + Env: []corev1.EnvVar{ + {Name: "LOG_LEVEL", Value: "debug"}, + {Name: "CONFIG_ONLY", Value: "config"}, + }, + }, + }, + }, + expectedEnvs: map[string]string{ + "LOG_LEVEL": "debug", // GatewayConfig overrides global + "GLOBAL_ONLY": "global", // global only + "CONFIG_ONLY": "config", // config only + }, + }, + { + name: "GatewayConfig with nil ExtProc", + globalEnvVars: "GLOBAL_VAR=global-value", + gatewayConfig: &aigv1a1.GatewayConfig{ + Spec: aigv1a1.GatewayConfigSpec{ + ExtProc: nil, + }, + }, + expectedEnvs: map[string]string{ + "GLOBAL_VAR": "global-value", + }, + }, + { + name: "empty both", + globalEnvVars: "", + gatewayConfig: nil, + expectedEnvs: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := requireNewFakeClientWithIndexes(t) + fakeKube := fake2.NewClientset() + g := newTestGatewayMutator(fakeClient, fakeKube, "", "", "", tt.globalEnvVars, "", false) + + result := g.mergeEnvVars(tt.gatewayConfig) + + // Convert result to map for easier comparison. + resultMap := make(map[string]string) + for _, env := range result { + resultMap[env.Name] = env.Value + } + + require.Equal(t, tt.expectedEnvs, resultMap) + }) + } +} diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_aigatewayroutes.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_aigatewayroutes.yaml index 279af926d2..c69175ff95 100644 --- a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_aigatewayroutes.yaml +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_aigatewayroutes.yaml @@ -85,6 +85,11 @@ spec: Resources required by the external processor container. More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + Deprecated: Use GatewayConfig for gateway-scoped resource configuration instead. + Configure resources using GatewayConfig.spec.extProc.resources and reference it + from the Gateway via the "aigateway.envoyproxy.io/gateway-config" annotation. + This field will be removed in a future version. + Note: when multiple AIGatewayRoute resources are attached to the same Gateway, and each AIGatewayRoute has a different resource configuration, the ai-gateway will pick one of them to configure the resource requirements of the external processor container. diff --git a/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_gatewayconfigs.yaml b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_gatewayconfigs.yaml new file mode 100644 index 0000000000..66b62214b7 --- /dev/null +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_gatewayconfigs.yaml @@ -0,0 +1,376 @@ +# Copyright Envoy AI Gateway Authors +# SPDX-License-Identifier: Apache-2.0 +# The full text of the Apache license is available in the LICENSE file at +# the root of the repo. + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: gatewayconfigs.aigateway.envoyproxy.io +spec: + group: aigateway.envoyproxy.io + names: + kind: GatewayConfig + listKind: GatewayConfigList + plural: gatewayconfigs + shortNames: + - gwconfig + singular: gatewayconfig + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[-1:].type + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + GatewayConfig provides configuration for the AI Gateway external processor + container that is deployed alongside the Gateway. + + A GatewayConfig is referenced by a Gateway via the annotation + "aigateway.envoyproxy.io/gateway-config". The GatewayConfig must be in the + same namespace as the Gateway that references it. + + This allows gateway-level configuration of the external processor, including + environment variables (e.g., for tracing configuration) and resource requirements. + + Multiple Gateways can reference the same GatewayConfig to share configuration. + + Environment Variable Precedence: + When merging environment variables, the following precedence applies (highest to lowest): + 1. GatewayConfig.Spec.ExtProc.Env (this resource) + 2. Global controller flags (extProcExtraEnvVars) + + If the same environment variable name exists in both sources, the GatewayConfig + value takes precedence. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Spec defines the configuration for the external processor. + properties: + extProc: + description: ExtProc defines the configuration for the external processor + container. + properties: + env: + description: |- + Env specifies environment variables to be added to the external processor container. + These variables are merged with any global environment variables configured in the + AI Gateway controller. If there are conflicting variable names, the values defined + here take precedence over global values. + + Common use cases include: + - OTEL tracing configuration (e.g., OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT) + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the + specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount containing + the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the + exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + maxItems: 32 + type: array + resources: + description: |- + Resources specifies the compute resources required by the external processor container. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + + If not specified, the external processor will use Kubernetes default resource allocations. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + type: object + status: + description: Status defines the status of the GatewayConfig. + properties: + conditions: + description: Conditions describe the current conditions of the GatewayConfig. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index e41fec6615..63a0c97d49 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -21,6 +21,8 @@ API group. - [AIServiceBackendList](#aiservicebackendlist) - [BackendSecurityPolicy](#backendsecuritypolicy) - [BackendSecurityPolicyList](#backendsecuritypolicylist) +- [GatewayConfig](#gatewayconfig) +- [GatewayConfigList](#gatewayconfiglist) - [MCPRoute](#mcproute) - [MCPRouteList](#mcproutelist) @@ -286,6 +288,103 @@ BackendSecurityPolicyList contains a list of BackendSecurityPolicy /> +#### GatewayConfig + + + +**Appears in:** +- [GatewayConfigList](#gatewayconfiglist) + +GatewayConfig provides configuration for the AI Gateway external processor +container that is deployed alongside the Gateway. + +A GatewayConfig is referenced by a Gateway via the annotation +"aigateway.envoyproxy.io/gateway-config". The GatewayConfig must be in the +same namespace as the Gateway that references it. + +This allows gateway-level configuration of the external processor, including +environment variables (e.g., for tracing configuration) and resource requirements. + +Multiple Gateways can reference the same GatewayConfig to share configuration. + +Environment Variable Precedence: +When merging environment variables, the following precedence applies (highest to lowest): + 1. GatewayConfig.Spec.ExtProc.Env (this resource) + 2. Global controller flags (extProcExtraEnvVars) + +If the same environment variable name exists in both sources, the GatewayConfig +value takes precedence. + +##### Fields + + + + + + + + +#### GatewayConfigList + + + + +GatewayConfigList contains a list of GatewayConfig. + +##### Fields + + + + + + + + #### MCPRoute @@ -400,6 +499,9 @@ MCPRouteList contains a list of MCPRoute. - [GCPServiceAccountImpersonationConfig](#gcpserviceaccountimpersonationconfig) - [GCPWorkloadIdentityFederationConfig](#gcpworkloadidentityfederationconfig) - [GCPWorkloadIdentityProvider](#gcpworkloadidentityprovider) +- [GatewayConfigExtProc](#gatewayconfigextproc) +- [GatewayConfigSpec](#gatewayconfigspec) +- [GatewayConfigStatus](#gatewayconfigstatus) - [HTTPBodyField](#httpbodyfield) - [HTTPBodyMutation](#httpbodymutation) - [HTTPHeaderMutation](#httpheadermutation) @@ -462,7 +564,7 @@ MCPRouteList contains a list of MCPRoute. name="resources" type="[ResourceRequirements](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#resourcerequirements-v1-core)" required="false" - description="Resources required by the external processor container.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
Note: when multiple AIGatewayRoute resources are attached to the same Gateway, and each
AIGatewayRoute has a different resource configuration, the ai-gateway will pick one of them
to configure the resource requirements of the external processor container." + description="Resources required by the external processor container.
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
Deprecated: Use GatewayConfig for gateway-scoped resource configuration instead.
Configure resources using GatewayConfig.spec.extProc.resources and reference it
from the Gateway via the `aigateway.envoyproxy.io/gateway-config` annotation.
This field will be removed in a future version.
Note: when multiple AIGatewayRoute resources are attached to the same Gateway, and each
AIGatewayRoute has a different resource configuration, the ai-gateway will pick one of them
to configure the resource requirements of the external processor container." /> @@ -1337,6 +1439,74 @@ GCPCredentialsFile specifies the service account key json file to authenticate w +#### GatewayConfigExtProc + + + +**Appears in:** +- [GatewayConfigSpec](#gatewayconfigspec) + +GatewayConfigExtProc defines the configuration for the external processor container. + +##### Fields + + + + + + +#### GatewayConfigSpec + + + +**Appears in:** +- [GatewayConfig](#gatewayconfig) + +GatewayConfigSpec defines the configuration for the AI Gateway. + +##### Fields + + + + + + +#### GatewayConfigStatus + + + +**Appears in:** +- [GatewayConfig](#gatewayconfig) + +GatewayConfigStatus defines the observed state of GatewayConfig. + +##### Fields + + + + + + #### HTTPBodyField diff --git a/site/docs/capabilities/gateway-config.md b/site/docs/capabilities/gateway-config.md new file mode 100644 index 0000000000..411638892c --- /dev/null +++ b/site/docs/capabilities/gateway-config.md @@ -0,0 +1,242 @@ +--- +id: gateway-config +title: Gateway Configuration +sidebar_position: 2 +--- + +# Gateway Configuration + +The `GatewayConfig` CRD provides gateway-scoped configuration for the AI Gateway external processor container. This allows you to configure environment variables and resource requirements at the Gateway level, rather than at the route level. + +## Overview + +Use `GatewayConfig` when you need to: + +- Configure per-gateway OpenTelemetry tracing settings +- Set resource requirements (CPU/memory) for the external processor +- Share configuration across multiple Gateways +- Configure environment variables for different gateway instances without affecting others + +## Usage + +### Creating a GatewayConfig + +Create a `GatewayConfig` resource with your desired configuration: + +```yaml +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: GatewayConfig +metadata: + name: my-gateway-config + namespace: default +spec: + extProc: + env: + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://otel-collector:4317" + - name: OTEL_SERVICE_NAME + value: "my-ai-gateway" + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" +``` + +### Referencing from a Gateway + +Reference the `GatewayConfig` from your Gateway using an annotation: + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: my-gateway + namespace: default + annotations: + aigateway.envoyproxy.io/gateway-config: my-gateway-config +spec: + gatewayClassName: envoy-gateway + listeners: + - name: http + protocol: HTTP + port: 8080 +``` + +:::note +The `GatewayConfig` must be in the same namespace as the Gateway that references it. +::: + +## Configuration Options + +### Environment Variables + +The `spec.extProc.env` field accepts a list of Kubernetes `EnvVar` objects: + +```yaml +spec: + extProc: + env: + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://otel-collector:4317" + - name: OTEL_EXPORTER_OTLP_HEADERS + value: "api-key=your-secret" + - name: LOG_LEVEL + value: "debug" +``` + +### Resource Requirements + +The `spec.extProc.resources` field configures compute resources for the external processor container: + +```yaml +spec: + extProc: + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" +``` + +If not specified, Kubernetes default resource allocations are used. + +## Environment Variable Precedence + +Environment variables can be configured at multiple levels. The precedence order is (highest to lowest): + +1. **GatewayConfig.spec.extProc.env** - Highest priority +2. **Global controller flags** (`--extproc-extra-env-vars`) - Lower priority + +When the same environment variable is defined at multiple levels, the higher precedence value is used. + +### Example + +If the controller is started with: + +``` +--extproc-extra-env-vars="LOG_LEVEL=info;GLOBAL_VAR=global" +``` + +And a GatewayConfig defines: + +```yaml +env: + - name: LOG_LEVEL + value: "debug" + - name: CONFIG_VAR + value: "config" +``` + +The resulting environment variables will be: + +- `LOG_LEVEL=debug` (GatewayConfig overrides global) +- `GLOBAL_VAR=global` (from global) +- `CONFIG_VAR=config` (from GatewayConfig) + +## Shared Configuration + +Multiple Gateways can reference the same `GatewayConfig`: + +```yaml +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: GatewayConfig +metadata: + name: shared-config +spec: + extProc: + env: + - name: OTEL_SERVICE_NAME + value: "ai-gateway-cluster" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-1 + annotations: + aigateway.envoyproxy.io/gateway-config: shared-config +spec: + # ... +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-2 + annotations: + aigateway.envoyproxy.io/gateway-config: shared-config +spec: + # ... +``` + +## Migration from Route-Level Configuration + +The route-level resource configuration (`AIGatewayRoute.spec.filterConfig.externalProcessor.resources`) is deprecated. Migrate to `GatewayConfig`: + +### Before (deprecated) + +```yaml +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: AIGatewayRoute +metadata: + name: my-route +spec: + filterConfig: + type: ExternalProcessor + externalProcessor: + resources: + requests: + cpu: "100m" + memory: "128Mi" +``` + +### After (recommended) + +```yaml +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: GatewayConfig +metadata: + name: my-config +spec: + extProc: + resources: + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: my-gateway + annotations: + aigateway.envoyproxy.io/gateway-config: my-config +spec: + # ... +``` + +## Status + +The `GatewayConfig` status reports the validity of the configuration: + +```yaml +status: + conditions: + - type: Accepted + status: "True" + reason: Accepted + message: "GatewayConfig reconciled successfully" +``` + +Possible condition types: + +- `Accepted`: The configuration is valid and applied +- `NotAccepted`: The configuration has validation errors + +## See Also + +- [Tracing](./observability/tracing.md) - Configure distributed tracing for AI Gateway +- [Metrics](./observability/metrics.md) - Configure metrics collection +- [Examples](https://github.com/envoyproxy/ai-gateway/tree/main/examples/gateway-config) - Example YAML files diff --git a/site/docs/capabilities/index.md b/site/docs/capabilities/index.md index b29ce76043..83306765b5 100644 --- a/site/docs/capabilities/index.md +++ b/site/docs/capabilities/index.md @@ -25,6 +25,10 @@ Advanced inference optimization capabilities for AI/LLM workloads: - **[HTTPRoute + InferencePool](./inference/httproute-inferencepool.md)**: Basic inference routing with standard Gateway API - **[AIGatewayRoute + InferencePool](./inference/aigatewayroute-inferencepool.md)**: Advanced AI-specific routing with enhanced features +## Gateway Configuration + +- **[GatewayConfig](./gateway-config.md)**: Gateway-scoped configuration for the external processor (env vars, resources, shared settings) + ## Traffic Management Comprehensive traffic handling and routing capabilities: diff --git a/site/docs/capabilities/observability/index.md b/site/docs/capabilities/observability/index.md index b656bc2ae9..3b528f51f3 100644 --- a/site/docs/capabilities/observability/index.md +++ b/site/docs/capabilities/observability/index.md @@ -13,3 +13,4 @@ The Envoy AI Gateway provides specialized observability capabilities for AI and - **[GenAI Metrics](./metrics.md)** - Prometheus metrics following OpenTelemetry Gen AI semantic conventions for monitoring token usage, latency, and model performance. - **[GenAI Tracing](./tracing.md)** - OpenTelemetry integration with OpenInference semantic conventions for LLM request tracing and evaluation. - **[Access Logs with AI/LLM metadata](./accesslogs.md)** - AI metadata produced by the AI gateway (model name, token usage, etc.) can be included in the Envoy Access Logs. +- **[Gateway Configuration](../gateway-config.md)** - Per-gateway configuration of the external processor container, including environment variables for tracing and resource requirements. diff --git a/site/docs/capabilities/observability/tracing.md b/site/docs/capabilities/observability/tracing.md index 999f1064c0..69b3cf467c 100644 --- a/site/docs/capabilities/observability/tracing.md +++ b/site/docs/capabilities/observability/tracing.md @@ -192,8 +192,47 @@ helm upgrade ai-eg oci://docker.io/envoyproxy/ai-gateway-helm \\ --unset extProc.extraEnvVars`} +## Per-Gateway Configuration + +For deployments with multiple Gateways that need different tracing configurations, +use the `GatewayConfig` CRD instead of global Helm values. This allows you to: + +- Configure different OTEL endpoints for different Gateways +- Set per-gateway service names for better trace organization +- Override global tracing settings for specific Gateways + +### Example + +```yaml +apiVersion: aigateway.envoyproxy.io/v1alpha1 +kind: GatewayConfig +metadata: + name: production-tracing + namespace: default +spec: + extProc: + env: + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "http://production-collector:4317" + - name: OTEL_SERVICE_NAME + value: "ai-gateway-production" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: production-gateway + annotations: + aigateway.envoyproxy.io/gateway-config: production-tracing +spec: + # ... +``` + +See the [Gateway Configuration](../gateway-config.md) guide for detailed information +on `GatewayConfig` usage, including environment variable precedence and shared configurations. + ## See Also +- [Gateway Configuration](../gateway-config.md) - Per-gateway configuration using GatewayConfig - [OpenInference Specification][openinference] - GenAI Semantic conventions for traces - [OpenTelemetry Configuration][otel-config] - Environment variable reference - [Arize Phoenix Documentation][phoenix] - LLM observability platform