From d93fec7a24b97dc17391c6edb19db5f98f79e653 Mon Sep 17 00:00:00 2001 From: Shabab Qaisar Date: Fri, 5 Dec 2025 20:10:23 +0500 Subject: [PATCH 1/3] feat: introduce GatewayConfig CRD for gateway-scoped configuration This commit adds the GatewayConfig custom resource definition (CRD) to manage configuration for the AI Gateway external processor. It allows users to define environment variables and resource requirements at the gateway level, enhancing flexibility and reusability across multiple gateways. The AIGatewayRoute's resource configuration is now deprecated in favor of this new approach. Additionally, the GatewayConfig controller is implemented to handle the lifecycle of GatewayConfig resources, including finalizer management to prevent accidental deletions while still referenced by Gateways. Documentation updates are included to reflect the new configuration options and migration guidance from the deprecated route-level settings. Signed-off-by: Shabab Qaisar Signed-off-by: Shabab Qaisar --- api/v1alpha1/ai_gateway_route.go | 5 + .../typed/api/v1alpha1/api_client.go | 5 + .../api/v1alpha1/fake/fake_api_client.go | 4 + .../api/v1alpha1/fake/fake_gatewayconfig.go | 41 ++ .../typed/api/v1alpha1/gatewayconfig.go | 59 +++ .../typed/api/v1alpha1/generated_expansion.go | 2 + .../api/v1alpha1/gatewayconfig.go | 91 +++++ .../api/v1alpha1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../api/v1alpha1/expansion_generated.go | 8 + .../listers/api/v1alpha1/gatewayconfig.go | 59 +++ api/v1alpha1/gateway_config.go | 106 +++++ api/v1alpha1/registry.go | 3 + api/v1alpha1/zz_generated.deepcopy.go | 128 ++++++ cmd/aigw/translate.go | 5 + examples/gateway-config/comprehensive.yaml | 64 +++ internal/controller/ai_gateway_route.go | 6 + internal/controller/controller.go | 8 + internal/controller/gateway_config.go | 228 +++++++++++ internal/controller/gateway_config_test.go | 344 ++++++++++++++++ internal/controller/gateway_mutator.go | 90 +++- internal/controller/gateway_mutator_test.go | 92 +++++ ...gateway.envoyproxy.io_aigatewayroutes.yaml | 5 + ...igateway.envoyproxy.io_gatewayconfigs.yaml | 384 ++++++++++++++++++ site/docs/api/api.mdx | 178 +++++++- .../observability/gateway-config.md | 253 ++++++++++++ site/docs/capabilities/observability/index.md | 1 + .../capabilities/observability/tracing.md | 39 ++ 28 files changed, 2208 insertions(+), 9 deletions(-) create mode 100644 api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/fake/fake_gatewayconfig.go create mode 100644 api/v1alpha1/client/clientset/versioned/typed/api/v1alpha1/gatewayconfig.go create mode 100644 api/v1alpha1/client/informers/externalversions/api/v1alpha1/gatewayconfig.go create mode 100644 api/v1alpha1/client/listers/api/v1alpha1/gatewayconfig.go create mode 100644 api/v1alpha1/gateway_config.go create mode 100644 examples/gateway-config/comprehensive.yaml create mode 100644 internal/controller/gateway_config.go create mode 100644 internal/controller/gateway_config_test.go create mode 100644 manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_gatewayconfigs.yaml create mode 100644 site/docs/capabilities/observability/gateway-config.md 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..82a4353a6a --- /dev/null +++ b/api/v1alpha1/gateway_config.go @@ -0,0 +1,106 @@ +// 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. +// +// Finalizer Behavior: +// A finalizer is automatically added to the GatewayConfig when it is first referenced +// by a Gateway. The finalizer is removed when no Gateways reference it, allowing +// the GatewayConfig to be deleted. This prevents accidental deletion of configurations +// that are still in use. +// +// +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) + // - Log level overrides + // - Feature flags + // + // +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..de6b8b4920 100644 --- a/internal/controller/ai_gateway_route.go +++ b/internal/controller/ai_gateway_route.go @@ -43,6 +43,12 @@ 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" + // GatewayConfigFinalizerName is the finalizer added to GatewayConfig resources to prevent deletion + // while they are still referenced by Gateway objects. + GatewayConfigFinalizerName = "aigateway.envoyproxy.io/gateway-config-protection" ) // AIGatewayRouteController implements [reconcile.TypedReconciler]. diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 29de46da90..b7de131683 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -211,6 +211,14 @@ 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{}). + Watches(&gwapiv1.Gateway{}, handler.EnqueueRequestsFromMapFunc(gatewayConfigC.MapGatewayToGatewayConfig)). + 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{}). diff --git a/internal/controller/gateway_config.go b/internal/controller/gateway_config.go new file mode 100644 index 0000000000..800207a215 --- /dev/null +++ b/internal/controller/gateway_config.go @@ -0,0 +1,228 @@ +// 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" + ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "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 manages finalizers based on Gateway references. +// +// 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) + } + + // Handle finalizer based on whether any Gateways reference this GatewayConfig. + if gatewayConfig.DeletionTimestamp != nil { + // GatewayConfig is being deleted. + if len(referencingGateways) > 0 { + // Cannot delete yet - Gateways still reference this GatewayConfig. + c.logger.Info("GatewayConfig is being deleted but still has referencing Gateways", + "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name, + "referencingGateways", len(referencingGateways)) + return fmt.Errorf("cannot delete GatewayConfig: still referenced by %d Gateway(s)", len(referencingGateways)) + } + // No more references, remove finalizer. + if ctrlutil.ContainsFinalizer(gatewayConfig, GatewayConfigFinalizerName) { + ctrlutil.RemoveFinalizer(gatewayConfig, GatewayConfigFinalizerName) + if err := c.client.Update(ctx, gatewayConfig); err != nil { + return fmt.Errorf("failed to remove finalizer: %w", err) + } + c.logger.Info("Removed finalizer from GatewayConfig", + "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name) + } + return nil + } + + // GatewayConfig is not being deleted. + // Add finalizer if there are referencing Gateways and finalizer is not present. + if len(referencingGateways) > 0 && !ctrlutil.ContainsFinalizer(gatewayConfig, GatewayConfigFinalizerName) { + ctrlutil.AddFinalizer(gatewayConfig, GatewayConfigFinalizerName) + if err := c.client.Update(ctx, gatewayConfig); err != nil { + return fmt.Errorf("failed to add finalizer: %w", err) + } + c.logger.Info("Added finalizer to GatewayConfig", + "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name) + } + + // Remove finalizer if no Gateways reference this GatewayConfig. + if len(referencingGateways) == 0 && ctrlutil.ContainsFinalizer(gatewayConfig, GatewayConfigFinalizerName) { + ctrlutil.RemoveFinalizer(gatewayConfig, GatewayConfigFinalizerName) + if err := c.client.Update(ctx, gatewayConfig); err != nil { + return fmt.Errorf("failed to remove finalizer: %w", err) + } + c.logger.Info("Removed finalizer from GatewayConfig (no more references)", + "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name) + } + + // Notify all referencing Gateways to reconcile. + 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} + } + + 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)); err != nil { + return nil, fmt.Errorf("failed to list Gateways: %w", err) + } + + var referencingGateways []*gwapiv1.Gateway + for i := range gateways.Items { + gw := &gateways.Items[i] + if gw.Annotations == nil { + continue + } + configName, ok := gw.Annotations[GatewayConfigAnnotationKey] + if !ok { + continue + } + if configName == gatewayConfig.Name { + referencingGateways = append(referencingGateways, gw) + } + } + + return referencingGateways, nil +} + +// MapGatewayToGatewayConfig is a handler function that maps Gateway events to GatewayConfig reconcile requests. +// This is used by the controller builder to watch Gateway resources. +func (c *GatewayConfigController) MapGatewayToGatewayConfig(_ context.Context, obj client.Object) []reconcile.Request { + gateway, ok := obj.(*gwapiv1.Gateway) + if !ok { + return nil + } + + // Check if this Gateway has a GatewayConfig annotation. + if gateway.Annotations == nil { + return nil + } + + configName, ok := gateway.Annotations[GatewayConfigAnnotationKey] + if !ok || configName == "" { + return nil + } + + // Return a reconcile request for the referenced GatewayConfig. + // GatewayConfig must be in the same namespace as the Gateway. + c.logger.Info("Gateway references GatewayConfig, triggering reconcile", + "gateway_namespace", gateway.Namespace, "gateway_name", gateway.Name, + "gatewayconfig_name", configName) + + return []reconcile.Request{ + { + NamespacedName: client.ObjectKey{ + Name: configName, + Namespace: gateway.Namespace, + }, + }, + } +} + +// 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..d82ae81b62 --- /dev/null +++ b/internal/controller/gateway_config_test.go @@ -0,0 +1,344 @@ +// 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" + "testing" + + "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{}) + 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_FinalizerManagement(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.NotContains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + + // 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 add finalizer now. + _, 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.Contains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + + // Gateway event should be sent. + events := eventCh.RequireItemsEventually(t, 1) + require.Len(t, events, 1) + + // Delete the Gateway reference by updating it. + gateway.Annotations = nil + err = fakeClient.Update(t.Context(), gateway) + require.NoError(t, err) + + // Reconcile - should remove 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.NotContains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) +} + +func TestGatewayConfigController_MapGatewayToGatewayConfig(t *testing.T) { + fakeClient := requireNewFakeClientForGatewayConfig(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) + + t.Run("Gateway with GatewayConfig annotation", func(t *testing.T) { + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + Annotations: map[string]string{ + GatewayConfigAnnotationKey: "my-config", + }, + }, + } + + requests := c.MapGatewayToGatewayConfig(context.Background(), gateway) + require.Len(t, requests, 1) + require.Equal(t, "my-config", requests[0].Name) + require.Equal(t, "default", requests[0].Namespace) + }) + + t.Run("Gateway without annotation", func(t *testing.T) { + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + }, + } + + requests := c.MapGatewayToGatewayConfig(context.Background(), gateway) + require.Empty(t, requests) + }) + + t.Run("Gateway with empty annotation", func(t *testing.T) { + gateway := &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-gateway", + Namespace: "default", + Annotations: map[string]string{ + GatewayConfigAnnotationKey: "", + }, + }, + } + + requests := c.MapGatewayToGatewayConfig(context.Background(), gateway) + require.Empty(t, requests) + }) +} + +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 add finalizer and 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.Contains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + + // Both Gateways should have been notified. + events := eventCh.RequireItemsEventually(t, 2) + require.Len(t, events, 2) +} + +func TestGatewayConfigController_DeletionBlocked(t *testing.T) { + fakeClient := requireNewFakeClientForGatewayConfig(t) + eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() + c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) + + // Create a GatewayConfig first. + gatewayConfig := &aigv1a1.GatewayConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: "default", + Finalizers: []string{GatewayConfigFinalizerName}, + }, + 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 to verify the GatewayConfig is accepted since it has references. + _, err = c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, + }) + require.NoError(t, err) + + // Get the GatewayConfig to verify finalizer exists. + var updatedConfig aigv1a1.GatewayConfig + err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) + require.NoError(t, err) + require.Contains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + + // Try to delete the GatewayConfig (simulate via Delete API). + // With finalizer, it won't actually be deleted but will have DeletionTimestamp set. + err = fakeClient.Delete(t.Context(), &updatedConfig) + require.NoError(t, err) + + // Get the GatewayConfig again - it should still exist with DeletionTimestamp. + err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) + require.NoError(t, err) + require.NotNil(t, updatedConfig.DeletionTimestamp) + + // Reconcile again - should fail because GatewayConfig is still referenced. + _, err = c.Reconcile(t.Context(), reconcile.Request{ + NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "still referenced") +} diff --git a/internal/controller/gateway_mutator.go b/internal/controller/gateway_mutator.go index e164862579..dc54d201ed 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.fetchGatewayAndConfig(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,73 @@ func (g *gatewayMutator) mutatePod(ctx context.Context, pod *corev1.Pod, gateway } return nil } + +// fetchGatewayAndConfig returns the Gateway and the referenced GatewayConfig (if present). +func (g *gatewayMutator) fetchGatewayAndConfig(ctx context.Context, gatewayName, gatewayNamespace string) (*gwapiv1.Gateway, *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 defaults", + "gateway_name", gatewayName, "gateway_namespace", gatewayNamespace) + } else { + g.logger.Error(err, "failed to get Gateway, using global defaults", + "gateway_name", gatewayName, "gateway_namespace", gatewayNamespace) + } + return nil, nil + } + + // Check for GatewayConfig annotation. + if gateway.Annotations == nil { + return &gateway, nil + } + + configName, ok := gateway.Annotations[GatewayConfigAnnotationKey] + if !ok || configName == "" { + return &gateway, 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 &gateway, nil + } + + g.logger.Info("found GatewayConfig for Gateway", + "gateway_name", gatewayName, "gatewayconfig_name", configName) + return &gateway, &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..dfe4924a85 --- /dev/null +++ b/manifests/charts/ai-gateway-crds-helm/templates/aigateway.envoyproxy.io_gatewayconfigs.yaml @@ -0,0 +1,384 @@ +# 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. + + Finalizer Behavior: + A finalizer is automatically added to the GatewayConfig when it is first referenced + by a Gateway. The finalizer is removed when no Gateways reference it, allowing + the GatewayConfig to be deleted. This prevents accidental deletion of configurations + that are still in use. + 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) + - Log level overrides + - Feature flags + 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..96633b10c1 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,109 @@ 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. + +Finalizer Behavior: +A finalizer is automatically added to the GatewayConfig when it is first referenced +by a Gateway. The finalizer is removed when no Gateways reference it, allowing +the GatewayConfig to be deleted. This prevents accidental deletion of configurations +that are still in use. + +##### Fields + + + + + + + + +#### GatewayConfigList + + + + +GatewayConfigList contains a list of GatewayConfig. + +##### Fields + + + + + + + + #### MCPRoute @@ -400,6 +505,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 +570,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 +1445,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/observability/gateway-config.md b/site/docs/capabilities/observability/gateway-config.md new file mode 100644 index 0000000000..df040c4e5a --- /dev/null +++ b/site/docs/capabilities/observability/gateway-config.md @@ -0,0 +1,253 @@ +--- +id: gateway-config +title: Gateway Configuration +sidebar_position: 9 +--- + +# 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: + # ... +``` + +## Finalizer Protection + +A finalizer is automatically managed on `GatewayConfig` resources: + +- **Added**: When the first Gateway references the GatewayConfig +- **Removed**: When no Gateways reference it anymore + +This prevents accidental deletion of configurations that are still in use. + +Attempting to delete a GatewayConfig that is still referenced by a Gateway will result in the deletion being blocked until all references are removed. + +## 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](tracing.md) - Configure distributed tracing for AI Gateway +- [Metrics](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/observability/index.md b/site/docs/capabilities/observability/index.md index b656bc2ae9..315bd2402c 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..6d9116b228 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 From bc64bbe689d148103681bd17ee38e37976bd7d25 Mon Sep 17 00:00:00 2001 From: Shabab Qaisar Date: Sat, 6 Dec 2025 18:41:41 +0500 Subject: [PATCH 2/3] refactor: update GatewayConfig controller to remove finalizer management This commit refactors the GatewayConfig controller to eliminate finalizer management, simplifying the lifecycle handling of GatewayConfig resources. The controller now focuses on notifying referencing Gateways of changes without blocking deletion based on references. Additionally, the documentation has been updated to reflect these changes, including the removal of finalizer behavior descriptions from the API and capability documentation. Signed-off-by: Shabab Qaisar Signed-off-by: Shabab Qaisar --- api/v1alpha1/gateway_config.go | 8 -- internal/controller/ai_gateway_route.go | 3 - internal/controller/controller.go | 21 ++- internal/controller/gateway_config.go | 107 +++------------- internal/controller/gateway_config_test.go | 120 +++--------------- internal/controller/gateway_mutator.go | 23 ++-- ...igateway.envoyproxy.io_gatewayconfigs.yaml | 8 -- site/docs/api/api.mdx | 8 +- .../{observability => }/gateway-config.md | 17 +-- site/docs/capabilities/index.md | 4 + 10 files changed, 73 insertions(+), 246 deletions(-) rename site/docs/capabilities/{observability => }/gateway-config.md (89%) diff --git a/api/v1alpha1/gateway_config.go b/api/v1alpha1/gateway_config.go index 82a4353a6a..d702e53986 100644 --- a/api/v1alpha1/gateway_config.go +++ b/api/v1alpha1/gateway_config.go @@ -30,12 +30,6 @@ import ( // If the same environment variable name exists in both sources, the GatewayConfig // value takes precedence. // -// Finalizer Behavior: -// A finalizer is automatically added to the GatewayConfig when it is first referenced -// by a Gateway. The finalizer is removed when no Gateways reference it, allowing -// the GatewayConfig to be deleted. This prevents accidental deletion of configurations -// that are still in use. -// // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:object:root=true @@ -78,8 +72,6 @@ type GatewayConfigExtProc struct { // // Common use cases include: // - OTEL tracing configuration (e.g., OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT) - // - Log level overrides - // - Feature flags // // +optional // +kubebuilder:validation:MaxItems=32 diff --git a/internal/controller/ai_gateway_route.go b/internal/controller/ai_gateway_route.go index de6b8b4920..f19c8d354a 100644 --- a/internal/controller/ai_gateway_route.go +++ b/internal/controller/ai_gateway_route.go @@ -46,9 +46,6 @@ const ( // 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" - // GatewayConfigFinalizerName is the finalizer added to GatewayConfig resources to prevent deletion - // while they are still referenced by Gateway objects. - GatewayConfigFinalizerName = "aigateway.envoyproxy.io/gateway-config-protection" ) // AIGatewayRouteController implements [reconcile.TypedReconciler]. diff --git a/internal/controller/controller.go b/internal/controller/controller.go index b7de131683..47bc25d48d 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -214,7 +214,6 @@ func StartControllers(ctx context.Context, mgr manager.Manager, config *rest.Con // GatewayConfig controller for gateway-scoped configuration. gatewayConfigC := NewGatewayConfigController(c, logger.WithName("gateway-config"), gatewayEventChan) if err = TypedControllerBuilderForCRD(mgr, &aigv1a1.GatewayConfig{}). - Watches(&gwapiv1.Gateway{}, handler.EnqueueRequestsFromMapFunc(gatewayConfigC.MapGatewayToGatewayConfig)). Complete(gatewayConfigC); err != nil { return fmt.Errorf("failed to create controller for GatewayConfig: %w", err) } @@ -282,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 // @@ -313,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) @@ -336,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 index 800207a215..23cbc56351 100644 --- a/internal/controller/gateway_config.go +++ b/internal/controller/gateway_config.go @@ -15,7 +15,6 @@ import ( "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - ctrlutil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/reconcile" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -25,7 +24,7 @@ import ( // GatewayConfigController implements [reconcile.TypedReconciler] for [aigv1a1.GatewayConfig]. // -// This handles the GatewayConfig resource and manages finalizers based on Gateway references. +// This handles the GatewayConfig resource and notifies referencing Gateways of changes. // // Exported for testing purposes. type GatewayConfigController struct { @@ -78,56 +77,13 @@ func (c *GatewayConfigController) syncGatewayConfig(ctx context.Context, gateway return fmt.Errorf("failed to find referencing Gateways: %w", err) } - // Handle finalizer based on whether any Gateways reference this GatewayConfig. if gatewayConfig.DeletionTimestamp != nil { - // GatewayConfig is being deleted. - if len(referencingGateways) > 0 { - // Cannot delete yet - Gateways still reference this GatewayConfig. - c.logger.Info("GatewayConfig is being deleted but still has referencing Gateways", - "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name, - "referencingGateways", len(referencingGateways)) - return fmt.Errorf("cannot delete GatewayConfig: still referenced by %d Gateway(s)", len(referencingGateways)) - } - // No more references, remove finalizer. - if ctrlutil.ContainsFinalizer(gatewayConfig, GatewayConfigFinalizerName) { - ctrlutil.RemoveFinalizer(gatewayConfig, GatewayConfigFinalizerName) - if err := c.client.Update(ctx, gatewayConfig); err != nil { - return fmt.Errorf("failed to remove finalizer: %w", err) - } - c.logger.Info("Removed finalizer from GatewayConfig", - "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name) - } + c.notifyReferencingGateways(gatewayConfig, referencingGateways) return nil } - // GatewayConfig is not being deleted. - // Add finalizer if there are referencing Gateways and finalizer is not present. - if len(referencingGateways) > 0 && !ctrlutil.ContainsFinalizer(gatewayConfig, GatewayConfigFinalizerName) { - ctrlutil.AddFinalizer(gatewayConfig, GatewayConfigFinalizerName) - if err := c.client.Update(ctx, gatewayConfig); err != nil { - return fmt.Errorf("failed to add finalizer: %w", err) - } - c.logger.Info("Added finalizer to GatewayConfig", - "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name) - } - - // Remove finalizer if no Gateways reference this GatewayConfig. - if len(referencingGateways) == 0 && ctrlutil.ContainsFinalizer(gatewayConfig, GatewayConfigFinalizerName) { - ctrlutil.RemoveFinalizer(gatewayConfig, GatewayConfigFinalizerName) - if err := c.client.Update(ctx, gatewayConfig); err != nil { - return fmt.Errorf("failed to remove finalizer: %w", err) - } - c.logger.Info("Removed finalizer from GatewayConfig (no more references)", - "namespace", gatewayConfig.Namespace, "name", gatewayConfig.Name) - } - // Notify all referencing Gateways to reconcile. - 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} - } + c.notifyReferencingGateways(gatewayConfig, referencingGateways) return nil } @@ -135,59 +91,30 @@ func (c *GatewayConfigController) syncGatewayConfig(ctx context.Context, gateway // 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)); err != nil { + 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) } - var referencingGateways []*gwapiv1.Gateway + referencingGateways := make([]*gwapiv1.Gateway, 0, len(gateways.Items)) for i := range gateways.Items { gw := &gateways.Items[i] - if gw.Annotations == nil { - continue - } - configName, ok := gw.Annotations[GatewayConfigAnnotationKey] - if !ok { - continue - } - if configName == gatewayConfig.Name { - referencingGateways = append(referencingGateways, gw) - } + referencingGateways = append(referencingGateways, gw) } return referencingGateways, nil } -// MapGatewayToGatewayConfig is a handler function that maps Gateway events to GatewayConfig reconcile requests. -// This is used by the controller builder to watch Gateway resources. -func (c *GatewayConfigController) MapGatewayToGatewayConfig(_ context.Context, obj client.Object) []reconcile.Request { - gateway, ok := obj.(*gwapiv1.Gateway) - if !ok { - return nil - } - - // Check if this Gateway has a GatewayConfig annotation. - if gateway.Annotations == nil { - return nil - } - - configName, ok := gateway.Annotations[GatewayConfigAnnotationKey] - if !ok || configName == "" { - return nil - } - - // Return a reconcile request for the referenced GatewayConfig. - // GatewayConfig must be in the same namespace as the Gateway. - c.logger.Info("Gateway references GatewayConfig, triggering reconcile", - "gateway_namespace", gateway.Namespace, "gateway_name", gateway.Name, - "gatewayconfig_name", configName) - - return []reconcile.Request{ - { - NamespacedName: client.ObjectKey{ - Name: configName, - Namespace: gateway.Namespace, - }, - }, +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} } } diff --git a/internal/controller/gateway_config_test.go b/internal/controller/gateway_config_test.go index d82ae81b62..2274dc5a45 100644 --- a/internal/controller/gateway_config_test.go +++ b/internal/controller/gateway_config_test.go @@ -6,8 +6,8 @@ package controller import ( - "context" "testing" + "time" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -27,7 +27,8 @@ import ( func requireNewFakeClientForGatewayConfig(t *testing.T) client.Client { t.Helper() builder := fake.NewClientBuilder().WithScheme(Scheme). - WithStatusSubresource(&aigv1a1.GatewayConfig{}) + WithStatusSubresource(&aigv1a1.GatewayConfig{}). + WithIndex(&gwapiv1.Gateway{}, k8sClientIndexGatewayToGatewayConfig, gatewayToGatewayConfigIndexFunc) return builder.Build() } @@ -74,7 +75,7 @@ func TestGatewayConfigController_Reconcile(t *testing.T) { require.Equal(t, aigv1a1.ConditionTypeAccepted, updated.Status.Conditions[0].Type) } -func TestGatewayConfigController_FinalizerManagement(t *testing.T) { +func TestGatewayConfigController_NotifyGateways(t *testing.T) { fakeClient := requireNewFakeClientForGatewayConfig(t) eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) @@ -105,7 +106,7 @@ func TestGatewayConfigController_FinalizerManagement(t *testing.T) { var updatedConfig aigv1a1.GatewayConfig err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) require.NoError(t, err) - require.NotContains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + require.Empty(t, updatedConfig.Finalizers) // Create a Gateway that references the GatewayConfig. gateway := &gwapiv1.Gateway{ @@ -130,7 +131,7 @@ func TestGatewayConfigController_FinalizerManagement(t *testing.T) { err = fakeClient.Create(t.Context(), gateway) require.NoError(t, err) - // Reconcile again - should add finalizer now. + // 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"}, }) @@ -138,76 +139,11 @@ func TestGatewayConfigController_FinalizerManagement(t *testing.T) { err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) require.NoError(t, err) - require.Contains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + require.Empty(t, updatedConfig.Finalizers) // Gateway event should be sent. events := eventCh.RequireItemsEventually(t, 1) require.Len(t, events, 1) - - // Delete the Gateway reference by updating it. - gateway.Annotations = nil - err = fakeClient.Update(t.Context(), gateway) - require.NoError(t, err) - - // Reconcile - should remove 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.NotContains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) -} - -func TestGatewayConfigController_MapGatewayToGatewayConfig(t *testing.T) { - fakeClient := requireNewFakeClientForGatewayConfig(t) - eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() - c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) - - t.Run("Gateway with GatewayConfig annotation", func(t *testing.T) { - gateway := &gwapiv1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-gateway", - Namespace: "default", - Annotations: map[string]string{ - GatewayConfigAnnotationKey: "my-config", - }, - }, - } - - requests := c.MapGatewayToGatewayConfig(context.Background(), gateway) - require.Len(t, requests, 1) - require.Equal(t, "my-config", requests[0].Name) - require.Equal(t, "default", requests[0].Namespace) - }) - - t.Run("Gateway without annotation", func(t *testing.T) { - gateway := &gwapiv1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-gateway", - Namespace: "default", - }, - } - - requests := c.MapGatewayToGatewayConfig(context.Background(), gateway) - require.Empty(t, requests) - }) - - t.Run("Gateway with empty annotation", func(t *testing.T) { - gateway := &gwapiv1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-gateway", - Namespace: "default", - Annotations: map[string]string{ - GatewayConfigAnnotationKey: "", - }, - }, - } - - requests := c.MapGatewayToGatewayConfig(context.Background(), gateway) - require.Empty(t, requests) - }) } func TestGatewayConfigController_MultipleGatewaysReferencing(t *testing.T) { @@ -257,7 +193,7 @@ func TestGatewayConfigController_MultipleGatewaysReferencing(t *testing.T) { require.NoError(t, err) } - // Reconcile - should add finalizer and notify both gateways. + // Reconcile - should notify both gateways. _, err = c.Reconcile(t.Context(), reconcile.Request{ NamespacedName: client.ObjectKey{Name: "shared-config", Namespace: "default"}, }) @@ -266,24 +202,26 @@ func TestGatewayConfigController_MultipleGatewaysReferencing(t *testing.T) { var updatedConfig aigv1a1.GatewayConfig err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "shared-config", Namespace: "default"}, &updatedConfig) require.NoError(t, err) - require.Contains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) + require.Empty(t, updatedConfig.Finalizers) // Both Gateways should have been notified. events := eventCh.RequireItemsEventually(t, 2) require.Len(t, events, 2) } -func TestGatewayConfigController_DeletionBlocked(t *testing.T) { +func TestGatewayConfigController_DeletionDoesNotBlock(t *testing.T) { fakeClient := requireNewFakeClientForGatewayConfig(t) eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]() c := NewGatewayConfigController(fakeClient, ctrl.Log, eventCh.Ch) - // Create a GatewayConfig first. + deletionTime := metav1.NewTime(time.Now()) + + // Create a GatewayConfig marked for deletion. gatewayConfig := &aigv1a1.GatewayConfig{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-config", - Namespace: "default", - Finalizers: []string{GatewayConfigFinalizerName}, + Name: "test-config", + Namespace: "default", + DeletionTimestamp: &deletionTime, }, Spec: aigv1a1.GatewayConfigSpec{}, } @@ -313,32 +251,12 @@ func TestGatewayConfigController_DeletionBlocked(t *testing.T) { err = fakeClient.Create(t.Context(), gateway) require.NoError(t, err) - // Reconcile to verify the GatewayConfig is accepted since it has references. + // 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) - // Get the GatewayConfig to verify finalizer exists. - var updatedConfig aigv1a1.GatewayConfig - err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) - require.NoError(t, err) - require.Contains(t, updatedConfig.Finalizers, GatewayConfigFinalizerName) - - // Try to delete the GatewayConfig (simulate via Delete API). - // With finalizer, it won't actually be deleted but will have DeletionTimestamp set. - err = fakeClient.Delete(t.Context(), &updatedConfig) - require.NoError(t, err) - - // Get the GatewayConfig again - it should still exist with DeletionTimestamp. - err = fakeClient.Get(t.Context(), client.ObjectKey{Name: "test-config", Namespace: "default"}, &updatedConfig) - require.NoError(t, err) - require.NotNil(t, updatedConfig.DeletionTimestamp) - - // Reconcile again - should fail because GatewayConfig is still referenced. - _, err = c.Reconcile(t.Context(), reconcile.Request{ - NamespacedName: client.ObjectKey{Name: "test-config", Namespace: "default"}, - }) - require.Error(t, err) - require.Contains(t, err.Error(), "still referenced") + 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 dc54d201ed..d903598037 100644 --- a/internal/controller/gateway_mutator.go +++ b/internal/controller/gateway_mutator.go @@ -274,7 +274,7 @@ func (g *gatewayMutator) mutatePod(ctx context.Context, pod *corev1.Pod, gateway return fmt.Errorf("failed to get filter config secret: %w", err) } - _, gatewayConfig := g.fetchGatewayAndConfig(ctx, gatewayName, gatewayNamespace) + gatewayConfig := g.fetchGatewayConfig(ctx, gatewayName, gatewayNamespace) // Now we construct the AI Gateway managed containers and volumes. filterConfigSecretName := FilterConfigSecretPerGatewayName(gatewayName, gatewayNamespace) @@ -394,29 +394,24 @@ func (g *gatewayMutator) mutatePod(ctx context.Context, pod *corev1.Pod, gateway return nil } -// fetchGatewayAndConfig returns the Gateway and the referenced GatewayConfig (if present). -func (g *gatewayMutator) fetchGatewayAndConfig(ctx context.Context, gatewayName, gatewayNamespace string) (*gwapiv1.Gateway, *aigv1a1.GatewayConfig) { +// 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 defaults", + 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 defaults", + g.logger.Error(err, "failed to get Gateway, using global default configuration", "gateway_name", gatewayName, "gateway_namespace", gatewayNamespace) } - return nil, nil - } - - // Check for GatewayConfig annotation. - if gateway.Annotations == nil { - return &gateway, nil + return nil } configName, ok := gateway.Annotations[GatewayConfigAnnotationKey] if !ok || configName == "" { - return &gateway, nil + return nil } // Fetch the GatewayConfig (must be in same namespace as Gateway). @@ -429,12 +424,12 @@ func (g *gatewayMutator) fetchGatewayAndConfig(ctx context.Context, gatewayName, g.logger.Error(err, "failed to get GatewayConfig, using global defaults", "gateway_name", gatewayName, "gatewayconfig_name", configName) } - return &gateway, nil + return nil } g.logger.Info("found GatewayConfig for Gateway", "gateway_name", gatewayName, "gatewayconfig_name", configName) - return &gateway, &gatewayConfig + return &gatewayConfig } // mergeEnvVars merges env vars; GatewayConfig overrides global while preserving order. 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 index dfe4924a85..66b62214b7 100644 --- 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 @@ -48,12 +48,6 @@ spec: If the same environment variable name exists in both sources, the GatewayConfig value takes precedence. - - Finalizer Behavior: - A finalizer is automatically added to the GatewayConfig when it is first referenced - by a Gateway. The finalizer is removed when no Gateways reference it, allowing - the GatewayConfig to be deleted. This prevents accidental deletion of configurations - that are still in use. properties: apiVersion: description: |- @@ -88,8 +82,6 @@ spec: Common use cases include: - OTEL tracing configuration (e.g., OTEL_EXPORTER_OTLP_HEADERS, OTEL_EXPORTER_OTLP_ENDPOINT) - - Log level overrides - - Feature flags items: description: EnvVar represents an environment variable present in a Container. diff --git a/site/docs/api/api.mdx b/site/docs/api/api.mdx index 96633b10c1..63a0c97d49 100644 --- a/site/docs/api/api.mdx +++ b/site/docs/api/api.mdx @@ -315,12 +315,6 @@ When merging environment variables, the following precedence applies (highest to If the same environment variable name exists in both sources, the GatewayConfig value takes precedence. -Finalizer Behavior: -A finalizer is automatically added to the GatewayConfig when it is first referenced -by a Gateway. The finalizer is removed when no Gateways reference it, allowing -the GatewayConfig to be deleted. This prevents accidental deletion of configurations -that are still in use. - ##### Fields Date: Sat, 6 Dec 2025 18:41:41 +0500 Subject: [PATCH 3/3] refactor: update GatewayConfig controller to remove finalizer management This commit refactors the GatewayConfig controller to eliminate finalizer management, simplifying the lifecycle handling of GatewayConfig resources. The controller now focuses on notifying referencing Gateways of changes without blocking deletion based on references. Additionally, the documentation has been updated to reflect these changes, including the removal of finalizer behavior descriptions from the API and capability documentation. Signed-off-by: Shabab Qaisar Signed-off-by: Shabab Qaisar --- site/docs/capabilities/observability/index.md | 2 +- site/docs/capabilities/observability/tracing.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/site/docs/capabilities/observability/index.md b/site/docs/capabilities/observability/index.md index 315bd2402c..3b528f51f3 100644 --- a/site/docs/capabilities/observability/index.md +++ b/site/docs/capabilities/observability/index.md @@ -13,4 +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. +- **[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 6d9116b228..69b3cf467c 100644 --- a/site/docs/capabilities/observability/tracing.md +++ b/site/docs/capabilities/observability/tracing.md @@ -227,12 +227,12 @@ spec: # ... ``` -See the [Gateway Configuration](gateway-config.md) guide for detailed information +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 +- [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