From 49d8d834f1b1061f3c0ae6f0944998f971fa91ff Mon Sep 17 00:00:00 2001 From: jukie <10012479+Jukie@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:27:04 -0700 Subject: [PATCH 1/4] Add support for dynamic modules Signed-off-by: jukie <10012479+Jukie@users.noreply.github.com> --- api/v1alpha1/dynamic_module_types.go | 90 +++++ api/v1alpha1/envoyextensionypolicy_types.go | 11 + api/v1alpha1/envoyproxy_types.go | 21 +- api/v1alpha1/zz_generated.deepcopy.go | 74 ++++ ....envoyproxy.io_envoyextensionpolicies.yaml | 50 +++ .../gateway.envoyproxy.io_envoyproxies.yaml | 61 +++ ....envoyproxy.io_envoyextensionpolicies.yaml | 50 +++ .../gateway.envoyproxy.io_envoyproxies.yaml | 61 +++ internal/gatewayapi/envoyextensionpolicy.go | 117 +++++- ...extensionpolicy-with-dynamicmodule.in.yaml | 104 +++++ ...xtensionpolicy-with-dynamicmodule.out.yaml | 359 ++++++++++++++++++ ...npolicy-with-invalid-dynamicmodule.in.yaml | 65 ++++ ...policy-with-invalid-dynamicmodule.out.yaml | 230 +++++++++++ internal/ir/xds.go | 29 ++ internal/ir/zz_generated.deepcopy.go | 27 ++ internal/xds/translator/dynamicmodule.go | 153 ++++++++ internal/xds/translator/httpfilters.go | 2 + .../testdata/in/xds-ir/dynamicmodule.yaml | 90 +++++ .../out/xds-ir/dynamicmodule.clusters.yaml | 72 ++++ .../out/xds-ir/dynamicmodule.endpoints.yaml | 36 ++ .../out/xds-ir/dynamicmodule.listeners.yaml | 67 ++++ .../out/xds-ir/dynamicmodule.routes.yaml | 40 ++ site/content/en/latest/api/extension_types.md | 42 +- .../envoyextensionpolicy_test.go | 122 ++++++ test/cel-validation/envoyproxy_test.go | 89 +++++ 25 files changed, 2043 insertions(+), 19 deletions(-) create mode 100644 api/v1alpha1/dynamic_module_types.go create mode 100644 internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.in.yaml create mode 100644 internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.out.yaml create mode 100644 internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.in.yaml create mode 100644 internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.out.yaml create mode 100644 internal/xds/translator/dynamicmodule.go create mode 100644 internal/xds/translator/testdata/in/xds-ir/dynamicmodule.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/dynamicmodule.clusters.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/dynamicmodule.endpoints.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/dynamicmodule.listeners.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/dynamicmodule.routes.yaml diff --git a/api/v1alpha1/dynamic_module_types.go b/api/v1alpha1/dynamic_module_types.go new file mode 100644 index 0000000000..ec63163dba --- /dev/null +++ b/api/v1alpha1/dynamic_module_types.go @@ -0,0 +1,90 @@ +// Copyright Envoy 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 ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// DynamicModuleEntry defines a dynamic module that is registered and allowed +// for use by EnvoyExtensionPolicy resources. +type DynamicModuleEntry struct { + // Name is the logical name for this module. EnvoyExtensionPolicy resources + // reference modules by this name. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` + Name string `json:"name"` + + // LibraryName is the name of the shared library file that Envoy will load. + // Envoy searches for lib${libraryName}.so in the path specified by the + // ENVOY_DYNAMIC_MODULES_SEARCH_PATH environment variable. + // If not specified, defaults to the value of Name. + // + // +optional + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9_]([a-zA-Z0-9_.-]*[a-zA-Z0-9_])?$` + LibraryName *string `json:"libraryName,omitempty"` + + // DoNotClose prevents the module from being unloaded with dlclose when no + // more references exist. This is useful for modules that maintain global + // state that should not be destroyed on configuration updates. + // Defaults to false. + // + // +optional + // +kubebuilder:default=false + DoNotClose *bool `json:"doNotClose,omitempty"` + + // LoadGlobally loads the dynamic module with the RTLD_GLOBAL flag. + // By default, modules are loaded with RTLD_LOCAL to avoid symbol conflicts. + // Set this to true when the module needs to share symbols with other + // dynamic libraries it loads. + // Defaults to false. + // + // +optional + // +kubebuilder:default=false + LoadGlobally *bool `json:"loadGlobally,omitempty"` +} + +// DynamicModule defines a dynamic module HTTP filter to be loaded by Envoy. +// The module must be registered in the EnvoyProxy resource's dynamicModules +// allowlist by the infrastructure operator. +type DynamicModule struct { + // Name references a dynamic module registered in the EnvoyProxy resource's + // dynamicModules list. The referenced module must exist in the registry; + // otherwise, the policy will be rejected. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$` + Name string `json:"name"` + + // FilterName identifies a specific filter implementation within the dynamic + // module. A single shared library can contain multiple filter implementations. + // This value is passed to the module's HTTP filter config init function to + // select the appropriate implementation. + // If not specified, defaults to an empty string. + // + // +optional + // +kubebuilder:validation:MaxLength=253 + FilterName *string `json:"filterName,omitempty"` + + // Config is the configuration for the dynamic module filter. + // This is serialized as JSON and passed to the module's initialization function. + // + // +optional + Config *apiextensionsv1.JSON `json:"config,omitempty"` + + // TerminalFilter indicates that this dynamic module handles requests without + // requiring an upstream backend. The module is responsible for generating and + // sending the response to downstream directly. + // Defaults to false. + // + // +optional + // +kubebuilder:default=false + TerminalFilter *bool `json:"terminalFilter,omitempty"` +} diff --git a/api/v1alpha1/envoyextensionypolicy_types.go b/api/v1alpha1/envoyextensionypolicy_types.go index b3b2b3df64..2ce16f3c0b 100644 --- a/api/v1alpha1/envoyextensionypolicy_types.go +++ b/api/v1alpha1/envoyextensionypolicy_types.go @@ -65,6 +65,17 @@ type EnvoyExtensionPolicySpec struct { // +kubebuilder:validation:MaxItems=16 // +optional Lua []Lua `json:"lua,omitempty"` + + // DynamicModules is an ordered list of dynamic module HTTP filters + // that should be added to the envoy filter chain. + // Each module must be registered in the EnvoyProxy resource's dynamicModules + // allowlist. + // Order matters, as the filters will be loaded in the order they are + // defined in this list. + // + // +kubebuilder:validation:MaxItems=16 + // +optional + DynamicModules []DynamicModule `json:"dynamicModules,omitempty"` } //+kubebuilder:object:root=true diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go index 536c9d652c..4ce860bd39 100644 --- a/api/v1alpha1/envoyproxy_types.go +++ b/api/v1alpha1/envoyproxy_types.go @@ -132,6 +132,8 @@ type EnvoyProxySpec struct { // // - envoy.filters.http.wasm // + // - envoy.filters.http.dynamic_modules + // // - envoy.filters.http.rbac // // - envoy.filters.http.local_ratelimit @@ -182,6 +184,20 @@ type EnvoyProxySpec struct { // Default: Strict // +optional LuaValidation *LuaValidation `json:"luaValidation,omitempty"` + + // DynamicModules defines the set of dynamic modules that are allowed to be + // used by EnvoyExtensionPolicy resources. Each entry registers a module by + // a logical name and specifies the shared library that Envoy will load. + // + // The EnvoyProxy owner is responsible for ensuring the module .so files are available + // on the proxy container's filesystem (e.g., via init containers, custom images, + // or shared volumes). + // + // +kubebuilder:validation:MaxItems=16 + // +listType=map + // +listMapKey=name + // +optional + DynamicModules []DynamicModuleEntry `json:"dynamicModules,omitempty"` } // +kubebuilder:validation:Enum=Strict;InsecureSyntax;Disabled @@ -248,7 +264,7 @@ type FilterPosition struct { } // EnvoyFilter defines the type of Envoy HTTP filter. -// +kubebuilder:validation:Enum=envoy.filters.http.custom_response;envoy.filters.http.health_check;envoy.filters.http.fault;envoy.filters.http.cors;envoy.filters.http.header_mutation;envoy.filters.http.ext_authz;envoy.filters.http.api_key_auth;envoy.filters.http.basic_auth;envoy.filters.http.oauth2;envoy.filters.http.jwt_authn;envoy.filters.http.stateful_session;envoy.filters.http.buffer;envoy.filters.http.lua;envoy.filters.http.ext_proc;envoy.filters.http.wasm;envoy.filters.http.rbac;envoy.filters.http.local_ratelimit;envoy.filters.http.ratelimit;envoy.filters.http.grpc_web;envoy.filters.http.grpc_stats;envoy.filters.http.credential_injector;envoy.filters.http.compressor;envoy.filters.http.dynamic_forward_proxy +// +kubebuilder:validation:Enum=envoy.filters.http.custom_response;envoy.filters.http.health_check;envoy.filters.http.fault;envoy.filters.http.cors;envoy.filters.http.header_mutation;envoy.filters.http.ext_authz;envoy.filters.http.api_key_auth;envoy.filters.http.basic_auth;envoy.filters.http.oauth2;envoy.filters.http.jwt_authn;envoy.filters.http.stateful_session;envoy.filters.http.buffer;envoy.filters.http.lua;envoy.filters.http.ext_proc;envoy.filters.http.wasm;envoy.filters.http.dynamic_modules;envoy.filters.http.rbac;envoy.filters.http.local_ratelimit;envoy.filters.http.ratelimit;envoy.filters.http.grpc_web;envoy.filters.http.grpc_stats;envoy.filters.http.credential_injector;envoy.filters.http.compressor;envoy.filters.http.dynamic_forward_proxy type EnvoyFilter string const ( @@ -298,6 +314,9 @@ const ( // EnvoyFilterWasm defines the Envoy HTTP WebAssembly filter. EnvoyFilterWasm EnvoyFilter = "envoy.filters.http.wasm" + // EnvoyFilterDynamicModules defines the Envoy HTTP dynamic modules filter. + EnvoyFilterDynamicModules EnvoyFilter = "envoy.filters.http.dynamic_modules" + // EnvoyFilterRBAC defines the Envoy RBAC filter. EnvoyFilterRBAC EnvoyFilter = "envoy.filters.http.rbac" diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 8577b27ce1..b504bebf11 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1781,6 +1781,66 @@ func (in *DNS) DeepCopy() *DNS { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DynamicModule) DeepCopyInto(out *DynamicModule) { + *out = *in + if in.FilterName != nil { + in, out := &in.FilterName, &out.FilterName + *out = new(string) + **out = **in + } + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } + if in.TerminalFilter != nil { + in, out := &in.TerminalFilter, &out.TerminalFilter + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicModule. +func (in *DynamicModule) DeepCopy() *DynamicModule { + if in == nil { + return nil + } + out := new(DynamicModule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DynamicModuleEntry) DeepCopyInto(out *DynamicModuleEntry) { + *out = *in + if in.LibraryName != nil { + in, out := &in.LibraryName, &out.LibraryName + *out = new(string) + **out = **in + } + if in.DoNotClose != nil { + in, out := &in.DoNotClose, &out.DoNotClose + *out = new(bool) + **out = **in + } + if in.LoadGlobally != nil { + in, out := &in.LoadGlobally, &out.LoadGlobally + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicModuleEntry. +func (in *DynamicModuleEntry) DeepCopy() *DynamicModuleEntry { + if in == nil { + return nil + } + out := new(DynamicModuleEntry) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EndpointOverride) DeepCopyInto(out *EndpointOverride) { *out = *in @@ -1927,6 +1987,13 @@ func (in *EnvoyExtensionPolicySpec) DeepCopyInto(out *EnvoyExtensionPolicySpec) (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.DynamicModules != nil { + in, out := &in.DynamicModules, &out.DynamicModules + *out = make([]DynamicModule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvoyExtensionPolicySpec. @@ -2780,6 +2847,13 @@ func (in *EnvoyProxySpec) DeepCopyInto(out *EnvoyProxySpec) { *out = new(LuaValidation) **out = **in } + if in.DynamicModules != nil { + in, out := &in.DynamicModules, &out.DynamicModules + *out = make([]DynamicModuleEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvoyProxySpec. diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml index 996d26a91c..e461a51bc7 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml @@ -47,6 +47,56 @@ spec: spec: description: Spec defines the desired state of EnvoyExtensionPolicy. properties: + dynamicModules: + description: |- + DynamicModules is an ordered list of dynamic module HTTP filters + that should be added to the envoy filter chain. + Each module must be registered in the EnvoyProxy resource's dynamicModules + allowlist. + Order matters, as the filters will be loaded in the order they are + defined in this list. + items: + description: |- + DynamicModule defines a dynamic module HTTP filter to be loaded by Envoy. + The module must be registered in the EnvoyProxy resource's dynamicModules + allowlist by the infrastructure operator. + properties: + config: + description: |- + Config is the configuration for the dynamic module filter. + This is serialized as JSON and passed to the module's initialization function. + x-kubernetes-preserve-unknown-fields: true + filterName: + description: |- + FilterName identifies a specific filter implementation within the dynamic + module. A single shared library can contain multiple filter implementations. + This value is passed to the module's HTTP filter config init function to + select the appropriate implementation. + If not specified, defaults to an empty string. + maxLength: 253 + type: string + name: + description: |- + Name references a dynamic module registered in the EnvoyProxy resource's + dynamicModules list. The referenced module must exist in the registry; + otherwise, the policy will be rejected. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + terminalFilter: + default: false + description: |- + TerminalFilter indicates that this dynamic module handles requests without + requiring an upstream backend. The module is responsible for generating and + sending the response to downstream directly. + Defaults to false. + type: boolean + required: + - name + type: object + maxItems: 16 + type: array extProc: description: |- ExtProc is an ordered list of external processing filters diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyproxies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyproxies.yaml index 2931ebf19d..4f2a1dc204 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyproxies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_envoyproxies.yaml @@ -291,6 +291,62 @@ spec: the number of cpuset threads on the platform. format: int32 type: integer + dynamicModules: + description: |- + DynamicModules defines the set of dynamic modules that are allowed to be + used by EnvoyExtensionPolicy resources. Each entry registers a module by + a logical name and specifies the shared library that Envoy will load. + + The EnvoyProxy owner is responsible for ensuring the module .so files are available + on the proxy container's filesystem (e.g., via init containers, custom images, + or shared volumes). + items: + description: |- + DynamicModuleEntry defines a dynamic module that is registered and allowed + for use by EnvoyExtensionPolicy resources. + properties: + doNotClose: + default: false + description: |- + DoNotClose prevents the module from being unloaded with dlclose when no + more references exist. This is useful for modules that maintain global + state that should not be destroyed on configuration updates. + Defaults to false. + type: boolean + libraryName: + description: |- + LibraryName is the name of the shared library file that Envoy will load. + Envoy searches for lib${libraryName}.so in the path specified by the + ENVOY_DYNAMIC_MODULES_SEARCH_PATH environment variable. + If not specified, defaults to the value of Name. + maxLength: 253 + pattern: ^[a-zA-Z0-9_]([a-zA-Z0-9_.-]*[a-zA-Z0-9_])?$ + type: string + loadGlobally: + default: false + description: |- + LoadGlobally loads the dynamic module with the RTLD_GLOBAL flag. + By default, modules are loaded with RTLD_LOCAL to avoid symbol conflicts. + Set this to true when the module needs to share symbols with other + dynamic libraries it loads. + Defaults to false. + type: boolean + name: + description: |- + Name is the logical name for this module. EnvoyExtensionPolicy resources + reference modules by this name. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + required: + - name + type: object + maxItems: 16 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map extraArgs: description: |- ExtraArgs defines additional command line options that are provided to Envoy. @@ -336,6 +392,8 @@ spec: - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules + - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit @@ -379,6 +437,7 @@ spec: - envoy.filters.http.lua - envoy.filters.http.ext_proc - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit @@ -408,6 +467,7 @@ spec: - envoy.filters.http.lua - envoy.filters.http.ext_proc - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit @@ -435,6 +495,7 @@ spec: - envoy.filters.http.lua - envoy.filters.http.ext_proc - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml index 97aeacf69e..b95bad19bf 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml @@ -46,6 +46,56 @@ spec: spec: description: Spec defines the desired state of EnvoyExtensionPolicy. properties: + dynamicModules: + description: |- + DynamicModules is an ordered list of dynamic module HTTP filters + that should be added to the envoy filter chain. + Each module must be registered in the EnvoyProxy resource's dynamicModules + allowlist. + Order matters, as the filters will be loaded in the order they are + defined in this list. + items: + description: |- + DynamicModule defines a dynamic module HTTP filter to be loaded by Envoy. + The module must be registered in the EnvoyProxy resource's dynamicModules + allowlist by the infrastructure operator. + properties: + config: + description: |- + Config is the configuration for the dynamic module filter. + This is serialized as JSON and passed to the module's initialization function. + x-kubernetes-preserve-unknown-fields: true + filterName: + description: |- + FilterName identifies a specific filter implementation within the dynamic + module. A single shared library can contain multiple filter implementations. + This value is passed to the module's HTTP filter config init function to + select the appropriate implementation. + If not specified, defaults to an empty string. + maxLength: 253 + type: string + name: + description: |- + Name references a dynamic module registered in the EnvoyProxy resource's + dynamicModules list. The referenced module must exist in the registry; + otherwise, the policy will be rejected. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + terminalFilter: + default: false + description: |- + TerminalFilter indicates that this dynamic module handles requests without + requiring an upstream backend. The module is responsible for generating and + sending the response to downstream directly. + Defaults to false. + type: boolean + required: + - name + type: object + maxItems: 16 + type: array extProc: description: |- ExtProc is an ordered list of external processing filters diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml index a939a49317..faac967d54 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml @@ -290,6 +290,62 @@ spec: the number of cpuset threads on the platform. format: int32 type: integer + dynamicModules: + description: |- + DynamicModules defines the set of dynamic modules that are allowed to be + used by EnvoyExtensionPolicy resources. Each entry registers a module by + a logical name and specifies the shared library that Envoy will load. + + The EnvoyProxy owner is responsible for ensuring the module .so files are available + on the proxy container's filesystem (e.g., via init containers, custom images, + or shared volumes). + items: + description: |- + DynamicModuleEntry defines a dynamic module that is registered and allowed + for use by EnvoyExtensionPolicy resources. + properties: + doNotClose: + default: false + description: |- + DoNotClose prevents the module from being unloaded with dlclose when no + more references exist. This is useful for modules that maintain global + state that should not be destroyed on configuration updates. + Defaults to false. + type: boolean + libraryName: + description: |- + LibraryName is the name of the shared library file that Envoy will load. + Envoy searches for lib${libraryName}.so in the path specified by the + ENVOY_DYNAMIC_MODULES_SEARCH_PATH environment variable. + If not specified, defaults to the value of Name. + maxLength: 253 + pattern: ^[a-zA-Z0-9_]([a-zA-Z0-9_.-]*[a-zA-Z0-9_])?$ + type: string + loadGlobally: + default: false + description: |- + LoadGlobally loads the dynamic module with the RTLD_GLOBAL flag. + By default, modules are loaded with RTLD_LOCAL to avoid symbol conflicts. + Set this to true when the module needs to share symbols with other + dynamic libraries it loads. + Defaults to false. + type: boolean + name: + description: |- + Name is the logical name for this module. EnvoyExtensionPolicy resources + reference modules by this name. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ + type: string + required: + - name + type: object + maxItems: 16 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map extraArgs: description: |- ExtraArgs defines additional command line options that are provided to Envoy. @@ -335,6 +391,8 @@ spec: - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules + - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit @@ -378,6 +436,7 @@ spec: - envoy.filters.http.lua - envoy.filters.http.ext_proc - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit @@ -407,6 +466,7 @@ spec: - envoy.filters.http.lua - envoy.filters.http.ext_proc - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit @@ -434,6 +494,7 @@ spec: - envoy.filters.http.lua - envoy.filters.http.ext_proc - envoy.filters.http.wasm + - envoy.filters.http.dynamic_modules - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit diff --git a/internal/gatewayapi/envoyextensionpolicy.go b/internal/gatewayapi/envoyextensionpolicy.go index 20d3201966..c9b4029aa2 100644 --- a/internal/gatewayapi/envoyextensionpolicy.go +++ b/internal/gatewayapi/envoyextensionpolicy.go @@ -482,11 +482,11 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( resources *resource.Resources, ) error { var ( - wasms []ir.Wasm - luas []ir.Lua - wasmFailOpen, extProcFailOpen bool - wasmError, luaError, extProcError error - errs error + wasms []ir.Wasm + luas []ir.Lua + wasmFailOpen, extProcFailOpen bool + wasmError, luaError, extProcError, dynamicModuleError error + errs error ) if wasms, wasmError, wasmFailOpen = t.buildWasms(policy, resources); wasmError != nil { @@ -521,6 +521,12 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( errs = errors.Join(errs, extProcError) } + var dynamicModules []ir.DynamicModule + if dynamicModules, dynamicModuleError = t.buildDynamicModules(policy, gtwCtx.envoyProxy); dynamicModuleError != nil { + dynamicModuleError = perr.WithMessage(dynamicModuleError, "DynamicModule") + errs = errors.Join(errs, dynamicModuleError) + } + irKey := t.getIRKey(gtwCtx.Gateway) for _, listener := range parentRefCtx.listeners { irListener := xdsIR[irKey].GetHTTPListener(irListenerName(listener)) @@ -551,6 +557,9 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( if extProcError != nil { failRoute = failRoute || !extProcFailOpen } + if dynamicModuleError != nil { + failRoute = true + } if failRoute { r.DirectResponse = &ir.CustomResponse{ StatusCode: ptr.To(uint32(500)), @@ -558,9 +567,10 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute( routesWithDirectResponse.Insert(r.Name) } else { r.EnvoyExtensions = &ir.EnvoyExtensionFeatures{ - ExtProcs: extProcs, - Wasms: wasms, - Luas: luas, + ExtProcs: extProcs, + Wasms: wasms, + Luas: luas, + DynamicModules: dynamicModules, } } } @@ -587,12 +597,13 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway( resources *resource.Resources, ) error { var ( - extProcs []ir.ExtProc - wasms []ir.Wasm - luas []ir.Lua - wasmFailOpen, extProcFailOpen bool - wasmError, luaError, extProcError error - errs error + extProcs []ir.ExtProc + wasms []ir.Wasm + luas []ir.Lua + dynamicModules []ir.DynamicModule + wasmFailOpen, extProcFailOpen bool + wasmError, luaError, extProcError, dynamicModuleError error + errs error ) if extProcs, extProcError, extProcFailOpen = t.buildExtProcs(policy, resources, gateway.envoyProxy); extProcError != nil { @@ -607,6 +618,10 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway( luaError = perr.WithMessage(luaError, "Lua") errs = errors.Join(errs, luaError) } + if dynamicModules, dynamicModuleError = t.buildDynamicModules(policy, gateway.envoyProxy); dynamicModuleError != nil { + dynamicModuleError = perr.WithMessage(dynamicModuleError, "DynamicModule") + errs = errors.Join(errs, dynamicModuleError) + } irKey := t.getIRKey(gateway.Gateway) // Should exist since we've validated this @@ -645,6 +660,9 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway( if extProcError != nil { failRoute = failRoute || !extProcFailOpen } + if dynamicModuleError != nil { + failRoute = true + } if failRoute { r.DirectResponse = &ir.CustomResponse{ StatusCode: ptr.To(uint32(500)), @@ -652,9 +670,10 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway( routesWithDirectResponse.Insert(r.Name) } else { r.EnvoyExtensions = &ir.EnvoyExtensionFeatures{ - ExtProcs: extProcs, - Wasms: wasms, - Luas: luas, + ExtProcs: extProcs, + Wasms: wasms, + Luas: luas, + DynamicModules: dynamicModules, } } } @@ -1107,3 +1126,67 @@ func irConfigNameForWasm(policy client.Object, index int) string { irConfigName(policy), strconv.Itoa(index)) } + +func irConfigNameForDynamicModule(policy *egv1a1.EnvoyExtensionPolicy, index int) string { + return fmt.Sprintf( + "%s/dynamicmodule/%s", + irConfigName(policy), + strconv.Itoa(index)) +} + +func (t *Translator) buildDynamicModules( + policy *egv1a1.EnvoyExtensionPolicy, + envoyProxy *egv1a1.EnvoyProxy, +) ([]ir.DynamicModule, error) { + var errs error + + if policy == nil || len(policy.Spec.DynamicModules) == 0 { + return nil, nil + } + + // Build registry lookup map from EnvoyProxy + registry := make(map[string]*egv1a1.DynamicModuleEntry) + if envoyProxy != nil { + for i := range envoyProxy.Spec.DynamicModules { + entry := &envoyProxy.Spec.DynamicModules[i] + registry[entry.Name] = entry + } + } + + dmIRList := make([]ir.DynamicModule, 0, len(policy.Spec.DynamicModules)) + + for idx, dm := range policy.Spec.DynamicModules { + name := irConfigNameForDynamicModule(policy, idx) + + // Validate module exists in registry + entry, ok := registry[dm.Name] + if !ok { + errs = errors.Join(errs, fmt.Errorf("dynamic module %q is not registered in the EnvoyProxy dynamicModules allowlist", dm.Name)) + continue + } + + // Resolve library name (default to entry name) + moduleName := entry.Name + if entry.LibraryName != nil { + moduleName = *entry.LibraryName + } + + filterName := "" + if dm.FilterName != nil { + filterName = *dm.FilterName + } + + dmIR := ir.DynamicModule{ + Name: name, + ModuleName: moduleName, + FilterName: filterName, + Config: dm.Config, + DoNotClose: ptr.Deref(entry.DoNotClose, false), + LoadGlobally: ptr.Deref(entry.LoadGlobally, false), + TerminalFilter: ptr.Deref(dm.TerminalFilter, false), + } + dmIRList = append(dmIRList, dmIR) + } + + return dmIRList, errs +} diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.in.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.in.yaml new file mode 100644 index 0000000000..cce04dfee0 --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.in.yaml @@ -0,0 +1,104 @@ +envoyProxiesForGateways: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyProxy + metadata: + namespace: envoy-gateway + name: proxy-config + spec: + dynamicModules: + - name: my-auth-module + libraryName: my_auth + - name: ai-gateway-filter + libraryName: ai_gateway + doNotClose: true + loadGlobally: true +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: proxy-config + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - www.example.com + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-2 + spec: + hostnames: + - www.example.com + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/bar" + backendRefs: + - name: service-1 + port: 8080 +envoyextensionpolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: envoy-gateway + name: policy-for-gateway # This policy should attach to httproute-2 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + dynamicModules: + - name: my-auth-module + filterName: auth-check + config: + authEndpoint: https://auth.example.com +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: default + name: policy-for-http-route # This policy should attach to httproute-1 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + dynamicModules: + - name: ai-gateway-filter + filterName: token-ratelimit + config: + maxTokensPerMinute: 10000 diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.out.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.out.yaml new file mode 100644 index 0000000000..7b9586081c --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-dynamicmodule.out.yaml @@ -0,0 +1,359 @@ +envoyExtensionPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + name: policy-for-http-route + namespace: default + spec: + dynamicModules: + - config: + maxTokensPerMinute: 10000 + filterName: token-ratelimit + name: ai-gateway-filter + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: spec.targetRef is deprecated, use spec.targetRefs instead + reason: DeprecatedField + status: "True" + type: Warning + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + name: policy-for-gateway + namespace: envoy-gateway + spec: + dynamicModules: + - config: + authEndpoint: https://auth.example.com + filterName: auth-check + name: my-auth-module + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: spec.targetRef is deprecated, use spec.targetRefs instead + reason: DeprecatedField + status: "True" + type: Warning + - lastTransitionTime: null + message: 'This policy is being overridden by other envoyExtensionPolicies + for these routes: [default/httproute-1]' + reason: Overridden + status: "True" + type: Overridden + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: proxy-config + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 2 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + hostnames: + - www.example.com + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-2 + namespace: default + spec: + hostnames: + - www.example.com + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /bar + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + config: + apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyProxy + metadata: + name: proxy-config + namespace: envoy-gateway + spec: + dynamicModules: + - libraryName: my_auth + name: my-auth-module + - doNotClose: true + libraryName: ai_gateway + loadGlobally: true + name: ai-gateway-filter + logging: {} + status: {} + listeners: + - name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + envoyExtensions: + dynamicModules: + - config: + maxTokensPerMinute: 10000 + doNotClose: true + filterName: token-ratelimit + loadGlobally: true + moduleName: ai_gateway + name: envoyextensionpolicy/default/policy-for-http-route/dynamicmodule/0 + terminalFilter: false + hostname: www.example.com + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /foo + - destination: + metadata: + kind: HTTPRoute + name: httproute-2 + namespace: default + name: httproute/default/httproute-2/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-2/rule/0/backend/0 + protocol: HTTP + weight: 1 + envoyExtensions: + dynamicModules: + - config: + authEndpoint: https://auth.example.com + doNotClose: false + filterName: auth-check + loadGlobally: false + moduleName: my_auth + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/dynamicmodule/0 + terminalFilter: false + hostname: www.example.com + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-2 + namespace: default + name: httproute/default/httproute-2/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /bar + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.in.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.in.yaml new file mode 100644 index 0000000000..d48792b19a --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.in.yaml @@ -0,0 +1,65 @@ +envoyProxiesForGateways: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyProxy + metadata: + namespace: envoy-gateway + name: proxy-config + spec: + dynamicModules: + - name: allowed-module + libraryName: allowed +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: proxy-config + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - www.example.com + parentRefs: + - namespace: envoy-gateway + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/foo" + backendRefs: + - name: service-1 + port: 8080 +envoyextensionpolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + namespace: default + name: policy-with-unregistered-module + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + dynamicModules: + - name: unregistered-module + config: + key: value diff --git a/internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.out.yaml b/internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.out.yaml new file mode 100644 index 0000000000..b3db50ff52 --- /dev/null +++ b/internal/gatewayapi/testdata/envoyextensionpolicy-with-invalid-dynamicmodule.out.yaml @@ -0,0 +1,230 @@ +envoyExtensionPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyExtensionPolicy + metadata: + name: policy-with-unregistered-module + namespace: default + spec: + dynamicModules: + - config: + key: value + name: unregistered-module + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: 'DynamicModule: dynamic module "unregistered-module" is not registered + in the EnvoyProxy dynamicModules allowlist.' + reason: Invalid + status: "False" + type: Accepted + - lastTransitionTime: null + message: spec.targetRef is deprecated, use spec.targetRefs instead + reason: DeprecatedField + status: "True" + type: Warning + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: proxy-config + listeners: + - allowedRoutes: + namespaces: + from: All + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 1 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + hostnames: + - www.example.com + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: /foo + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + config: + apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: EnvoyProxy + metadata: + name: proxy-config + namespace: envoy-gateway + spec: + dynamicModules: + - libraryName: allowed + name: allowed-module + logging: {} + status: {} + listeners: + - name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: envoy-gateway/gateway-1 + namespace: envoy-gateway-system +xdsIR: + envoy-gateway/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-envoy-gateway-gateway-1-196ae069 + namespace: envoy-gateway-system + sectionName: "8080" + name: envoy-gateway/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + directResponse: + statusCode: 500 + hostname: www.example.com + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /foo + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index d41e5afca8..e723a12dd0 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -1081,6 +1081,8 @@ type EnvoyExtensionFeatures struct { Wasms []Wasm `json:"wasms,omitempty" yaml:"wasms,omitempty"` // Lua extensions Luas []Lua `json:"luas,omitempty" yaml:"luas,omitempty"` + // Dynamic Module extensions + DynamicModules []DynamicModule `json:"dynamicModules,omitempty" yaml:"dynamicModules,omitempty"` } // UnstructuredRef holds unstructured data for an arbitrary k8s resource introduced by an extension @@ -3418,6 +3420,33 @@ type HTTPWasmCode struct { OriginalURL string `json:"originalDownloadingURL"` } +// DynamicModule holds the information associated with a dynamic module HTTP filter. +// +k8s:deepcopy-gen=true +type DynamicModule struct { + // Name is a unique name for this dynamic module filter configuration. + // The xds translator generates one filter for each unique name. + Name string `json:"name"` + + // ModuleName is the library name that Envoy will load (resolved from registry). + // Envoy searches for lib${ModuleName}.so + ModuleName string `json:"moduleName"` + + // FilterName identifies the filter implementation within the module. + FilterName string `json:"filterName,omitempty"` + + // Config is the JSON configuration for the filter. + Config *apiextensionsv1.JSON `json:"config,omitempty"` + + // DoNotClose prevents the module from being unloaded. + DoNotClose bool `json:"doNotClose"` + + // LoadGlobally loads with RTLD_GLOBAL flag. + LoadGlobally bool `json:"loadGlobally"` + + // TerminalFilter indicates the module handles requests without upstream. + TerminalFilter bool `json:"terminalFilter"` +} + // DestinationFilters contains HTTP filters that will be used with the DestinationSetting. // +k8s:deepcopy-gen=true type DestinationFilters struct { diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index c746690126..a3e55d2a8e 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1172,6 +1172,26 @@ func (in *DestinationSetting) DeepCopy() *DestinationSetting { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DynamicModule) DeepCopyInto(out *DynamicModule) { + *out = *in + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(apiextensionsv1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DynamicModule. +func (in *DynamicModule) DeepCopy() *DynamicModule { + if in == nil { + return nil + } + out := new(DynamicModule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EndpointOverride) DeepCopyInto(out *EndpointOverride) { *out = *in @@ -1238,6 +1258,13 @@ func (in *EnvoyExtensionFeatures) DeepCopyInto(out *EnvoyExtensionFeatures) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.DynamicModules != nil { + in, out := &in.DynamicModules, &out.DynamicModules + *out = make([]DynamicModule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EnvoyExtensionFeatures. diff --git a/internal/xds/translator/dynamicmodule.go b/internal/xds/translator/dynamicmodule.go new file mode 100644 index 0000000000..c53974fa4c --- /dev/null +++ b/internal/xds/translator/dynamicmodule.go @@ -0,0 +1,153 @@ +// Copyright Envoy 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 translator + +import ( + "errors" + + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + dmconfigv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/dynamic_modules/v3" + dmfilterv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/dynamic_modules/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/structpb" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +func init() { + registerHTTPFilter(&dynamicModule{}) +} + +type dynamicModule struct{} + +var _ httpFilter = &dynamicModule{} + +// patchHCM builds and appends the dynamic module filters to the HTTP Connection Manager +// if applicable, and they do not already exist. +// Note: this method creates a filter for each route that contains a dynamic module config. +// The filter is disabled by default and enabled on the route level. +func (*dynamicModule) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { + var errs error + + if mgr == nil { + return errors.New("hcm is nil") + } + if irListener == nil { + return errors.New("ir listener is nil") + } + + for _, route := range irListener.Routes { + if !routeContainsDynamicModule(route) { + continue + } + for _, dm := range route.EnvoyExtensions.DynamicModules { + if hcmContainsFilter(mgr, dynamicModuleFilterName(&dm)) { + continue + } + filter, err := buildHCMDynamicModuleFilter(&dm) + if err != nil { + errs = errors.Join(errs, err) + continue + } + mgr.HttpFilters = append(mgr.HttpFilters, filter) + } + } + + return errs +} + +// buildHCMDynamicModuleFilter returns a dynamic module HTTP filter from the provided IR DynamicModule. +func buildHCMDynamicModuleFilter(dm *ir.DynamicModule) (*hcmv3.HttpFilter, error) { + dmProto, err := dynamicModuleConfig(dm) + if err != nil { + return nil, err + } + + dmAny, err := anypb.New(dmProto) + if err != nil { + return nil, err + } + + // All dynamic module filters for all Routes are aggregated on HCM and disabled by default. + // Per-route config is used to enable the relevant filters on appropriate routes. + return &hcmv3.HttpFilter{ + Name: dynamicModuleFilterName(dm), + Disabled: true, + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: dmAny, + }, + }, nil +} + +func dynamicModuleFilterName(dm *ir.DynamicModule) string { + return perRouteFilterName(egv1a1.EnvoyFilterDynamicModules, dm.Name) +} + +func dynamicModuleConfig(dm *ir.DynamicModule) (*dmfilterv3.DynamicModuleFilter, error) { + filterConfig := &dmfilterv3.DynamicModuleFilter{ + DynamicModuleConfig: &dmconfigv3.DynamicModuleConfig{ + Name: dm.ModuleName, + DoNotClose: dm.DoNotClose, + LoadGlobally: dm.LoadGlobally, + }, + FilterName: dm.FilterName, + TerminalFilter: dm.TerminalFilter, + } + + // Convert JSON config to google.protobuf.Struct wrapped in Any + if dm.Config != nil && dm.Config.Raw != nil { + s := &structpb.Struct{} + if err := s.UnmarshalJSON(dm.Config.Raw); err != nil { + return nil, err + } + configAny, err := anypb.New(s) + if err != nil { + return nil, err + } + filterConfig.FilterConfig = configAny + } + + return filterConfig, nil +} + +// routeContainsDynamicModule returns true if DynamicModules exist for the provided route. +func routeContainsDynamicModule(irRoute *ir.HTTPRoute) bool { + if irRoute == nil { + return false + } + return irRoute.EnvoyExtensions != nil && len(irRoute.EnvoyExtensions.DynamicModules) > 0 +} + +// patchResources is a no-op for dynamic modules: they are loaded from the local filesystem. +func (*dynamicModule) patchResources(_ *types.ResourceVersionTable, _ []*ir.HTTPRoute) error { + return nil +} + +// patchRoute enables the corresponding dynamic module filter for the provided route. +func (*dynamicModule) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute, _ *ir.HTTPListener) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if irRoute.EnvoyExtensions == nil { + return nil + } + + for _, dm := range irRoute.EnvoyExtensions.DynamicModules { + filterName := dynamicModuleFilterName(&dm) + if err := enableFilterOnRoute(route, filterName, &routev3.FilterConfig{ + Config: &anypb.Any{}, + }); err != nil { + return err + } + } + return nil +} diff --git a/internal/xds/translator/httpfilters.go b/internal/xds/translator/httpfilters.go index 6adceb9fff..cdfdad3fc6 100644 --- a/internal/xds/translator/httpfilters.go +++ b/internal/xds/translator/httpfilters.go @@ -129,6 +129,8 @@ func newOrderedHTTPFilter(filter *hcmv3.HttpFilter) *OrderedHTTPFilter { order = 100 + mustGetFilterIndex(filter.Name) case isFilterType(filter, egv1a1.EnvoyFilterWasm): order = 200 + mustGetFilterIndex(filter.Name) + case isFilterType(filter, egv1a1.EnvoyFilterDynamicModules): + order = 250 + mustGetFilterIndex(filter.Name) case isFilterType(filter, egv1a1.EnvoyFilterRBAC): order = 301 case isFilterType(filter, egv1a1.EnvoyFilterLocalRateLimit): diff --git a/internal/xds/translator/testdata/in/xds-ir/dynamicmodule.yaml b/internal/xds/translator/testdata/in/xds-ir/dynamicmodule.yaml new file mode 100644 index 0000000000..aad0161da5 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/dynamicmodule.yaml @@ -0,0 +1,90 @@ +http: +- address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: false + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + name: httproute/default/httproute-1/rule/0/backend/0 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /foo + envoyExtensions: + dynamicModules: + - config: + maxTokensPerMinute: 10000 + doNotClose: true + filterName: token-ratelimit + loadGlobally: true + moduleName: ai_gateway + name: envoyextensionpolicy/default/policy-for-http-route/dynamicmodule/0 + terminalFilter: false + - destination: + name: httproute/default/httproute-2/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + name: httproute/default/httproute-2/rule/0/backend/0 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-2/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /bar + envoyExtensions: + dynamicModules: + - config: + authEndpoint: https://auth.example.com + doNotClose: false + filterName: auth-check + loadGlobally: false + moduleName: my_auth + name: envoyextensionpolicy/envoy-gateway/policy-for-gateway/dynamicmodule/0 + terminalFilter: false + - destination: + name: httproute/default/httproute-3/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + name: httproute/default/httproute-3/rule/0/backend/0 + hostname: www.example.com + isHTTP2: false + name: httproute/default/httproute-3/rule/0/match/0/www_example_com + pathMatch: + distinct: false + name: "" + prefix: /terminal + envoyExtensions: + dynamicModules: + - doNotClose: false + filterName: handler + loadGlobally: false + moduleName: my_handler + name: envoyextensionpolicy/default/policy-terminal/dynamicmodule/0 + terminalFilter: true diff --git a/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.clusters.yaml new file mode 100644 index 0000000000..dd90c7218b --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.clusters.yaml @@ -0,0 +1,72 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-1/rule/0 + ignoreHealthOnHostRemoval: true + lbPolicy: LEAST_REQUEST + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: httproute/default/httproute-1/rule/0 + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-2/rule/0 + ignoreHealthOnHostRemoval: true + lbPolicy: LEAST_REQUEST + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: httproute/default/httproute-2/rule/0 + perConnectionBufferLimitBytes: 32768 + type: EDS +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-3/rule/0 + ignoreHealthOnHostRemoval: true + lbPolicy: LEAST_REQUEST + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: httproute/default/httproute-3/rule/0 + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.endpoints.yaml new file mode 100644 index 0000000000..dadc93ba2d --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.endpoints.yaml @@ -0,0 +1,36 @@ +- clusterName: httproute/default/httproute-1/rule/0 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 7.7.7.7 + portValue: 8080 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: httproute/default/httproute-1/rule/0/backend/0 +- clusterName: httproute/default/httproute-2/rule/0 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 7.7.7.7 + portValue: 8080 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: httproute/default/httproute-2/rule/0/backend/0 +- clusterName: httproute/default/httproute-3/rule/0 + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 7.7.7.7 + portValue: 8080 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: httproute/default/httproute-3/rule/0/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.listeners.yaml new file mode 100644 index 0000000000..f679794a08 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.listeners.yaml @@ -0,0 +1,67 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - disabled: true + name: envoy.filters.http.dynamic_modules/envoyextensionpolicy/default/policy-for-http-route/dynamicmodule/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter + dynamicModuleConfig: + doNotClose: true + loadGlobally: true + name: ai_gateway + filterConfig: + '@type': type.googleapis.com/google.protobuf.Struct + value: + maxTokensPerMinute: 10000 + filterName: token-ratelimit + - disabled: true + name: envoy.filters.http.dynamic_modules/envoyextensionpolicy/default/policy-terminal/dynamicmodule/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter + dynamicModuleConfig: + name: my_handler + filterName: handler + terminalFilter: true + - disabled: true + name: envoy.filters.http.dynamic_modules/envoyextensionpolicy/envoy-gateway/policy-for-gateway/dynamicmodule/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.dynamic_modules.v3.DynamicModuleFilter + dynamicModuleConfig: + name: my_auth + filterConfig: + '@type': type.googleapis.com/google.protobuf.Struct + value: + authEndpoint: https://auth.example.com + filterName: auth-check + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: envoy-gateway/gateway-1/http + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: envoy-gateway/gateway-1/http + maxConnectionsToAcceptPerSocketEvent: 1 + name: envoy-gateway/gateway-1/http + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.routes.yaml new file mode 100644 index 0000000000..d8a3a4d084 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/dynamicmodule.routes.yaml @@ -0,0 +1,40 @@ +- ignorePortInHostMatching: true + name: envoy-gateway/gateway-1/http + virtualHosts: + - domains: + - www.example.com + name: envoy-gateway/gateway-1/http/www_example_com + routes: + - match: + pathSeparatedPrefix: /foo + name: httproute/default/httproute-1/rule/0/match/0/www_example_com + route: + cluster: httproute/default/httproute-1/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.dynamic_modules/envoyextensionpolicy/default/policy-for-http-route/dynamicmodule/0: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + - match: + pathSeparatedPrefix: /bar + name: httproute/default/httproute-2/rule/0/match/0/www_example_com + route: + cluster: httproute/default/httproute-2/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.dynamic_modules/envoyextensionpolicy/envoy-gateway/policy-for-gateway/dynamicmodule/0: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + - match: + pathSeparatedPrefix: /terminal + name: httproute/default/httproute-3/rule/0/match/0/www_example_com + route: + cluster: httproute/default/httproute-3/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.dynamic_modules/envoyextensionpolicy/default/policy-terminal/dynamicmodule/0: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index bad944cd57..efc46d57f1 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1220,6 +1220,43 @@ _Appears in:_ | `IPv4AndIPv6` | IPv4AndIPv6DNSLookupFamily mean the DNS resolver will perform a lookup for both IPv4 and IPv6 families, and return all resolved
addresses. When this is used, Happy Eyeballs will be enabled for upstream connections.
| +#### DynamicModule + + + +DynamicModule defines a dynamic module HTTP filter to be loaded by Envoy. +The module must be registered in the EnvoyProxy resource's dynamicModules +allowlist by the infrastructure operator. + +_Appears in:_ +- [EnvoyExtensionPolicySpec](#envoyextensionpolicyspec) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | _string_ | true | | Name references a dynamic module registered in the EnvoyProxy resource's
dynamicModules list. The referenced module must exist in the registry;
otherwise, the policy will be rejected. | +| `filterName` | _string_ | false | | FilterName identifies a specific filter implementation within the dynamic
module. A single shared library can contain multiple filter implementations.
This value is passed to the module's HTTP filter config init function to
select the appropriate implementation.
If not specified, defaults to an empty string. | +| `config` | _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#json-v1-apiextensions-k8s-io)_ | false | | Config is the configuration for the dynamic module filter.
This is serialized as JSON and passed to the module's initialization function. | +| `terminalFilter` | _boolean_ | false | false | TerminalFilter indicates that this dynamic module handles requests without
requiring an upstream backend. The module is responsible for generating and
sending the response to downstream directly.
Defaults to false. | + + +#### DynamicModuleEntry + + + +DynamicModuleEntry defines a dynamic module that is registered and allowed +for use by EnvoyExtensionPolicy resources. + +_Appears in:_ +- [EnvoyProxySpec](#envoyproxyspec) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | _string_ | true | | Name is the logical name for this module. EnvoyExtensionPolicy resources
reference modules by this name. | +| `libraryName` | _string_ | false | | LibraryName is the name of the shared library file that Envoy will load.
Envoy searches for lib$\{libraryName\}.so in the path specified by the
ENVOY_DYNAMIC_MODULES_SEARCH_PATH environment variable.
If not specified, defaults to the value of Name. | +| `doNotClose` | _boolean_ | false | false | DoNotClose prevents the module from being unloaded with dlclose when no
more references exist. This is useful for modules that maintain global
state that should not be destroyed on configuration updates.
Defaults to false. | +| `loadGlobally` | _boolean_ | false | false | LoadGlobally loads the dynamic module with the RTLD_GLOBAL flag.
By default, modules are loaded with RTLD_LOCAL to avoid symbol conflicts.
Set this to true when the module needs to share symbols with other
dynamic libraries it loads.
Defaults to false. | + + #### EndpointOverride @@ -1300,6 +1337,7 @@ _Appears in:_ | `wasm` | _[Wasm](#wasm) array_ | false | | Wasm is a list of Wasm extensions to be loaded by the Gateway.
Order matters, as the extensions will be loaded in the order they are
defined in this list. | | `extProc` | _[ExtProc](#extproc) array_ | false | | ExtProc is an ordered list of external processing filters
that should be added to the envoy filter chain | | `lua` | _[Lua](#lua) array_ | false | | Lua is an ordered list of Lua filters
that should be added to the envoy filter chain | +| `dynamicModules` | _[DynamicModule](#dynamicmodule) array_ | false | | DynamicModules is an ordered list of dynamic module HTTP filters
that should be added to the envoy filter chain.
Each module must be registered in the EnvoyProxy resource's dynamicModules
allowlist.
Order matters, as the filters will be loaded in the order they are
defined in this list. | #### EnvoyFilter @@ -1328,6 +1366,7 @@ _Appears in:_ | `envoy.filters.http.lua` | EnvoyFilterLua defines the Envoy HTTP Lua filter.
| | `envoy.filters.http.ext_proc` | EnvoyFilterExtProc defines the Envoy HTTP external process filter.
| | `envoy.filters.http.wasm` | EnvoyFilterWasm defines the Envoy HTTP WebAssembly filter.
| +| `envoy.filters.http.dynamic_modules` | EnvoyFilterDynamicModules defines the Envoy HTTP dynamic modules filter.
| | `envoy.filters.http.rbac` | EnvoyFilterRBAC defines the Envoy RBAC filter.
| | `envoy.filters.http.local_ratelimit` | EnvoyFilterLocalRateLimit defines the Envoy HTTP local rate limit filter.
| | `envoy.filters.http.ratelimit` | EnvoyFilterRateLimit defines the Envoy HTTP rate limit filter.
| @@ -1873,11 +1912,12 @@ _Appears in:_ | `extraArgs` | _string array_ | false | | ExtraArgs defines additional command line options that are provided to Envoy.
More info: https://www.envoyproxy.io/docs/envoy/latest/operations/cli#command-line-options
Note: some command line options are used internally(e.g. --log-level) so they cannot be provided here. | | `mergeGateways` | _boolean_ | false | | MergeGateways defines if Gateway resources should be merged onto the same Envoy Proxy Infrastructure.
Setting this field to true would merge all Gateway Listeners under the parent Gateway Class.
This means that the port, protocol and hostname tuple must be unique for every listener.
If a duplicate listener is detected, the newer listener (based on timestamp) will be rejected and its status will be updated with a "Accepted=False" condition. | | `shutdown` | _[ShutdownConfig](#shutdownconfig)_ | false | | Shutdown defines configuration for graceful envoy shutdown process. | -| `filterOrder` | _[FilterPosition](#filterposition) array_ | false | | FilterOrder defines the order of filters in the Envoy proxy's HTTP filter chain.
The FilterPosition in the list will be applied in the order they are defined.
If unspecified, the default filter order is applied.
Default filter order is:
- envoy.filters.http.custom_response
- envoy.filters.http.health_check
- envoy.filters.http.fault
- envoy.filters.http.cors
- envoy.filters.http.header_mutation
- envoy.filters.http.ext_authz
- envoy.filters.http.api_key_auth
- envoy.filters.http.basic_auth
- envoy.filters.http.oauth2
- envoy.filters.http.jwt_authn
- envoy.filters.http.stateful_session
- envoy.filters.http.buffer
- envoy.filters.http.lua
- envoy.filters.http.ext_proc
- envoy.filters.http.wasm
- envoy.filters.http.rbac
- envoy.filters.http.local_ratelimit
- envoy.filters.http.ratelimit
- envoy.filters.http.grpc_web
- envoy.filters.http.grpc_stats
- envoy.filters.http.credential_injector
- envoy.filters.http.compressor
- envoy.filters.http.dynamic_forward_proxy
- envoy.filters.http.router
Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. | +| `filterOrder` | _[FilterPosition](#filterposition) array_ | false | | FilterOrder defines the order of filters in the Envoy proxy's HTTP filter chain.
The FilterPosition in the list will be applied in the order they are defined.
If unspecified, the default filter order is applied.
Default filter order is:
- envoy.filters.http.custom_response
- envoy.filters.http.health_check
- envoy.filters.http.fault
- envoy.filters.http.cors
- envoy.filters.http.header_mutation
- envoy.filters.http.ext_authz
- envoy.filters.http.api_key_auth
- envoy.filters.http.basic_auth
- envoy.filters.http.oauth2
- envoy.filters.http.jwt_authn
- envoy.filters.http.stateful_session
- envoy.filters.http.buffer
- envoy.filters.http.lua
- envoy.filters.http.ext_proc
- envoy.filters.http.wasm
- envoy.filters.http.dynamic_modules
- envoy.filters.http.rbac
- envoy.filters.http.local_ratelimit
- envoy.filters.http.ratelimit
- envoy.filters.http.grpc_web
- envoy.filters.http.grpc_stats
- envoy.filters.http.credential_injector
- envoy.filters.http.compressor
- envoy.filters.http.dynamic_forward_proxy
- envoy.filters.http.router
Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. | | `backendTLS` | _[BackendTLSConfig](#backendtlsconfig)_ | false | | BackendTLS is the TLS configuration for the Envoy proxy to use when connecting to backends.
These settings are applied on backends for which TLS policies are specified. | | `ipFamily` | _[IPFamily](#ipfamily)_ | false | | IPFamily specifies the IP family for the EnvoyProxy fleet.
This setting only affects the Gateway listener port and does not impact
other aspects of the Envoy proxy configuration.
If not specified, the system will operate as follows:
- It defaults to IPv4 only.
- IPv6 and dual-stack environments are not supported in this default configuration.
Note: To enable IPv6 or dual-stack functionality, explicit configuration is required. | | `preserveRouteOrder` | _boolean_ | false | | PreserveRouteOrder determines if the order of matching for HTTPRoutes is determined by Gateway-API
specification (https://gateway-api.sigs.k8s.io/reference/1.4/spec/#httprouterule)
or preserves the order defined by users in the HTTPRoute's HTTPRouteRule list.
Default: False | | `luaValidation` | _[LuaValidation](#luavalidation)_ | false | | LuaValidation determines strictness of the Lua script validation for Lua EnvoyExtensionPolicies
Default: Strict | +| `dynamicModules` | _[DynamicModuleEntry](#dynamicmoduleentry) array_ | false | | DynamicModules defines the set of dynamic modules that are allowed to be
used by EnvoyExtensionPolicy resources. Each entry registers a module by
a logical name and specifies the shared library that Envoy will load.
The EnvoyProxy owner is responsible for ensuring the module .so files are available
on the proxy container's filesystem (e.g., via init containers, custom images,
or shared volumes). | #### EnvoyProxyStatus diff --git a/test/cel-validation/envoyextensionpolicy_test.go b/test/cel-validation/envoyextensionpolicy_test.go index 6add146ea6..e3e4797e26 100644 --- a/test/cel-validation/envoyextensionpolicy_test.go +++ b/test/cel-validation/envoyextensionpolicy_test.go @@ -965,6 +965,128 @@ func TestEnvoyExtensionPolicyTarget(t *testing.T) { ": Exactly one of inline or valueRef must be set with correct type.", }, }, + // DynamicModules + { + desc: "valid DynamicModule with all fields", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + DynamicModules: []egv1a1.DynamicModule{ + { + Name: "my-module", + FilterName: ptr.To("my-filter"), + TerminalFilter: ptr.To(false), + }, + }, + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "eg", + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "valid DynamicModule with minimal fields", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + DynamicModules: []egv1a1.DynamicModule{ + { + Name: "my-module", + }, + }, + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "eg", + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "DynamicModule with empty name", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + DynamicModules: []egv1a1.DynamicModule{ + { + Name: "", + }, + }, + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "eg", + }, + }, + }, + } + }, + wantErrors: []string{ + "spec.dynamicModules[0].name: Invalid value:", + "should be at least 1 chars long", + }, + }, + { + desc: "multiple valid DynamicModules", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + DynamicModules: []egv1a1.DynamicModule{ + { + Name: "module-a", + FilterName: ptr.To("filter-a"), + }, + { + Name: "module-b", + TerminalFilter: ptr.To(true), + }, + }, + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "eg", + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "DynamicModule with terminalFilter true", + mutate: func(eep *egv1a1.EnvoyExtensionPolicy) { + eep.Spec = egv1a1.EnvoyExtensionPolicySpec{ + DynamicModules: []egv1a1.DynamicModule{ + { + Name: "terminal-module", + TerminalFilter: ptr.To(true), + }, + }, + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: "gateway.networking.k8s.io", + Kind: "Gateway", + Name: "eg", + }, + }, + }, + } + }, + wantErrors: []string{}, + }, { desc: "target selectors without targetRefs or targetRef", mutate: func(sp *egv1a1.EnvoyExtensionPolicy) { diff --git a/test/cel-validation/envoyproxy_test.go b/test/cel-validation/envoyproxy_test.go index 0272045075..70e4eeea87 100644 --- a/test/cel-validation/envoyproxy_test.go +++ b/test/cel-validation/envoyproxy_test.go @@ -2036,6 +2036,95 @@ func TestEnvoyProxyProvider(t *testing.T) { }, wantErrors: []string{"ImageRepository must contain only allowed characters and must not include a tag."}, }, + { + desc: "valid: dynamicModules with all fields", + mutate: func(envoy *egv1a1.EnvoyProxy) { + envoy.Spec = egv1a1.EnvoyProxySpec{ + DynamicModules: []egv1a1.DynamicModuleEntry{ + { + Name: "my-module", + LibraryName: ptr.To("my_module"), + DoNotClose: ptr.To(true), + LoadGlobally: ptr.To(true), + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "valid: dynamicModules with minimal fields", + mutate: func(envoy *egv1a1.EnvoyProxy) { + envoy.Spec = egv1a1.EnvoyProxySpec{ + DynamicModules: []egv1a1.DynamicModuleEntry{ + { + Name: "my-module", + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "valid: multiple dynamicModules", + mutate: func(envoy *egv1a1.EnvoyProxy) { + envoy.Spec = egv1a1.EnvoyProxySpec{ + DynamicModules: []egv1a1.DynamicModuleEntry{ + { + Name: "auth-module", + LibraryName: ptr.To("auth_lib"), + }, + { + Name: "rate-limiter", + LibraryName: ptr.To("rate_limit_lib"), + DoNotClose: ptr.To(true), + LoadGlobally: ptr.To(false), + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "invalid: dynamicModules with empty name", + mutate: func(envoy *egv1a1.EnvoyProxy) { + envoy.Spec = egv1a1.EnvoyProxySpec{ + DynamicModules: []egv1a1.DynamicModuleEntry{ + { + Name: "", + }, + }, + } + }, + wantErrors: []string{"spec.dynamicModules[0].name in body should be at least 1 chars long"}, + }, + { + desc: "invalid: dynamicModules name with uppercase", + mutate: func(envoy *egv1a1.EnvoyProxy) { + envoy.Spec = egv1a1.EnvoyProxySpec{ + DynamicModules: []egv1a1.DynamicModuleEntry{ + { + Name: "My-Module", + }, + }, + } + }, + wantErrors: []string{"spec.dynamicModules[0].name in body should match"}, + }, + { + desc: "invalid: dynamicModules libraryName with invalid chars", + mutate: func(envoy *egv1a1.EnvoyProxy) { + envoy.Spec = egv1a1.EnvoyProxySpec{ + DynamicModules: []egv1a1.DynamicModuleEntry{ + { + Name: "my-module", + LibraryName: ptr.To("my module!"), + }, + }, + } + }, + wantErrors: []string{"spec.dynamicModules[0].libraryName in body should match"}, + }, } for _, tc := range cases { From 92e2508478763344a2ce61e6db8b8345ec43dd1e Mon Sep 17 00:00:00 2001 From: jukie <10012479+Jukie@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:07:37 -0700 Subject: [PATCH 2/4] examples Signed-off-by: jukie <10012479+Jukie@users.noreply.github.com> --- examples/dynamic-module-test/Dockerfile | 18 +++++++++++ examples/dynamic-module-test/Makefile | 9 ++++++ examples/dynamic-module-test/go.mod | 5 +++ examples/dynamic-module-test/go.sum | 2 ++ examples/dynamic-module-test/main.go | 43 +++++++++++++++++++++++++ 5 files changed, 77 insertions(+) create mode 100644 examples/dynamic-module-test/Dockerfile create mode 100644 examples/dynamic-module-test/Makefile create mode 100644 examples/dynamic-module-test/go.mod create mode 100644 examples/dynamic-module-test/go.sum create mode 100644 examples/dynamic-module-test/main.go diff --git a/examples/dynamic-module-test/Dockerfile b/examples/dynamic-module-test/Dockerfile new file mode 100644 index 0000000000..6ae55d9427 --- /dev/null +++ b/examples/dynamic-module-test/Dockerfile @@ -0,0 +1,18 @@ +# ENVOY_VERSION must match the SDK version in go.mod to ensure ABI compatibility. +# Update both together when changing the target Envoy version. +ARG ENVOY_VERSION=v1.37.0 + +FROM golang:1.25.7 AS builder + +WORKDIR /build +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . ./ +RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=1 go build -buildmode=c-shared -o /build/libheader_mutation.so . + +ARG ENVOY_VERSION +FROM docker.io/envoyproxy/envoy:distroless-${ENVOY_VERSION} +COPY --from=builder /build/libheader_mutation.so /usr/local/lib/libheader_mutation.so diff --git a/examples/dynamic-module-test/Makefile b/examples/dynamic-module-test/Makefile new file mode 100644 index 0000000000..87c0bb68ec --- /dev/null +++ b/examples/dynamic-module-test/Makefile @@ -0,0 +1,9 @@ + +IMAGE_PREFIX ?= envoyproxy/gateway- +APP_NAME ?= dynamic-module-test +TAG ?= latest +ENVOY_VERSION ?= v1.37.0 + +.PHONY: docker-buildx +docker-buildx: + docker buildx build . --build-arg ENVOY_VERSION=$(ENVOY_VERSION) -t $(IMAGE_PREFIX)$(APP_NAME):$(TAG) --load diff --git a/examples/dynamic-module-test/go.mod b/examples/dynamic-module-test/go.mod new file mode 100644 index 0000000000..0ff481235c --- /dev/null +++ b/examples/dynamic-module-test/go.mod @@ -0,0 +1,5 @@ +module github.com/envoyproxy/gateway/examples/dynamic-module-test + +go 1.25.7 + +require github.com/envoyproxy/envoy/source/extensions/dynamic_modules v0.0.0-20260129014508-e8c1dc7dcbcd diff --git a/examples/dynamic-module-test/go.sum b/examples/dynamic-module-test/go.sum new file mode 100644 index 0000000000..0a7cef097d --- /dev/null +++ b/examples/dynamic-module-test/go.sum @@ -0,0 +1,2 @@ +github.com/envoyproxy/envoy/source/extensions/dynamic_modules v0.0.0-20260129014508-e8c1dc7dcbcd h1:IQMTVU/I2HaXkUUYvHD3gtUrFAGLZm8DcH/4Z20vRQQ= +github.com/envoyproxy/envoy/source/extensions/dynamic_modules v0.0.0-20260129014508-e8c1dc7dcbcd/go.mod h1:NpQosaDAX20s0ak0o/4b5dLOdvkbk15XqoakhSNX1Gg= diff --git a/examples/dynamic-module-test/main.go b/examples/dynamic-module-test/main.go new file mode 100644 index 0000000000..c3ac805685 --- /dev/null +++ b/examples/dynamic-module-test/main.go @@ -0,0 +1,43 @@ +// Copyright Envoy 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 main + +import ( + sdk "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go" + _ "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/abi" + "github.com/envoyproxy/envoy/source/extensions/dynamic_modules/sdk/go/shared" +) + +func init() { + sdk.RegisterHttpFilterConfigFactories(map[string]shared.HttpFilterConfigFactory{ + "header_mutation": &headerMutationConfigFactory{}, + }) +} + +func main() {} + +type headerMutationConfigFactory struct { + shared.EmptyHttpFilterConfigFactory +} + +func (f *headerMutationConfigFactory) Create(_ shared.HttpFilterConfigHandle, _ []byte) (shared.HttpFilterFactory, error) { + return &headerMutationFilterFactory{}, nil +} + +type headerMutationFilterFactory struct{} + +func (f *headerMutationFilterFactory) Create(handle shared.HttpFilterHandle) shared.HttpFilter { + return &headerMutationFilter{shared.EmptyHttpFilter{}} +} + +type headerMutationFilter struct { + shared.EmptyHttpFilter +} + +func (f *headerMutationFilter) OnResponseHeaders(headers shared.HeaderMap, _ bool) shared.HeadersStatus { + headers.Set("x-dynamic-module", "true") + return shared.HeadersStatusContinue +} From 8fd0fb840643cc67fe35ea4436a19abf61819897 Mon Sep 17 00:00:00 2001 From: jukie <10012479+Jukie@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:47:31 -0700 Subject: [PATCH 3/4] Add e2e Signed-off-by: jukie <10012479+Jukie@users.noreply.github.com> --- site/content/en/contributions/RELEASING.md | 7 +++ test/e2e/testdata/dynamic-module.yaml | 72 ++++++++++++++++++++++ test/e2e/tests/dynamic_module.go | 70 +++++++++++++++++++++ tools/hack/bump-envoy-dynamic-modules.sh | 61 ++++++++++++++++++ tools/make/examples.mk | 17 ++++- 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 test/e2e/testdata/dynamic-module.yaml create mode 100644 test/e2e/tests/dynamic_module.go create mode 100755 tools/hack/bump-envoy-dynamic-modules.sh diff --git a/site/content/en/contributions/RELEASING.md b/site/content/en/contributions/RELEASING.md index 2f9b80a138..7d84c10d23 100644 --- a/site/content/en/contributions/RELEASING.md +++ b/site/content/en/contributions/RELEASING.md @@ -65,6 +65,13 @@ export GITHUB_REMOTE=origin 9. Create a topic branch for updating the [Envoy proxy image][] and [Envoy Ratelimit image][] to the tag supported by the release. Please note that the tags should be updated in both the source code and the Helm chart. Reference [PR #5872][] for additional details on updating the image tag. + + (+v1.8.x only) After updating the Envoy proxy image tag, update the dynamic module SDK and example dependencies: + + ```shell + make update-dynamic-module-deps ENVOY_VERSION=v${ENVOY_PROXY_VERSION} + ``` + 10. Sign, commit, and push your changes to your fork. 11. Submit a [Pull Request][] to merge the changes into the `release/v${MAJOR_VERSION}.${MINOR_VERSION}` branch. 12. Do not proceed until your PR has merged into the release branch and the [Build and Test][] has completed for your PR. diff --git a/test/e2e/testdata/dynamic-module.yaml b/test/e2e/testdata/dynamic-module.yaml new file mode 100644 index 0000000000..305db2da8d --- /dev/null +++ b/test/e2e/testdata/dynamic-module.yaml @@ -0,0 +1,72 @@ +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyProxy +metadata: + name: dynamic-module-proxy + namespace: gateway-conformance-infra +spec: + provider: + type: Kubernetes + kubernetes: + envoyDeployment: + container: + image: envoyproxy/gateway-dynamic-module-test:latest + env: + - name: ENVOY_DYNAMIC_MODULES_SEARCH_PATH + value: /usr/local/lib + - name: GODEBUG + value: cgocheck=0 + dynamicModules: + - name: header-mutation + libraryName: header_mutation +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: dynamic-module-gateway + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + infrastructure: + parametersRef: + group: gateway.envoyproxy.io + kind: EnvoyProxy + name: dynamic-module-proxy + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: http-with-dynamic-module + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: dynamic-module-gateway + rules: + - matches: + - path: + type: PathPrefix + value: /dynamic-module + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: EnvoyExtensionPolicy +metadata: + name: dynamic-module-test + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: http-with-dynamic-module + dynamicModules: + - name: header-mutation + filterName: header_mutation diff --git a/test/e2e/tests/dynamic_module.go b/test/e2e/tests/dynamic_module.go new file mode 100644 index 0000000000..41621947e5 --- /dev/null +++ b/test/e2e/tests/dynamic_module.go @@ -0,0 +1,70 @@ +// Copyright Envoy 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. + +//go:build e2e + +package tests + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + + "github.com/envoyproxy/gateway/internal/gatewayapi" + "github.com/envoyproxy/gateway/internal/gatewayapi/resource" +) + +func init() { + ConformanceTests = append(ConformanceTests, DynamicModuleTest) +} + +var DynamicModuleTest = suite.ConformanceTest{ + ShortName: "DynamicModule", + Description: "Test dynamic module extension that adds response headers", + Manifests: []string{"testdata/dynamic-module.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("http route with dynamic module filter", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "http-with-dynamic-module", Namespace: ns} + gwNN := types.NamespacedName{Name: "dynamic-module-gateway", Namespace: ns} + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, routeNN) + + ancestorRef := gwapiv1.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + } + EnvoyExtensionPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "dynamic-module-test", Namespace: ns}, suite.ControllerName, ancestorRef) + + // Wait for the Envoy proxy pods to be running and ready. + gwPodNamespace := GetGatewayResourceNamespace() + WaitForPods(t, suite.Client, gwPodNamespace, map[string]string{ + "gateway.envoyproxy.io/owning-gateway-name": gwNN.Name, + "gateway.envoyproxy.io/owning-gateway-namespace": gwNN.Namespace, + }, corev1.PodRunning, &PodReady) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/dynamic-module", + }, + Response: http.Response{ + StatusCodes: []int{200}, + Headers: map[string]string{ + "x-dynamic-module": "true", + }, + }, + Namespace: ns, + } + + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }) + }, +} diff --git a/tools/hack/bump-envoy-dynamic-modules.sh b/tools/hack/bump-envoy-dynamic-modules.sh new file mode 100755 index 0000000000..1dd6fd8036 --- /dev/null +++ b/tools/hack/bump-envoy-dynamic-modules.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2038 + +set -euo pipefail + +ENVOY_VERSION="${1:?Usage: $0 }" + +# Extract major.minor from version (e.g., v1.37.0 -> 1.37) +MAJOR_MINOR=$(echo "${ENVOY_VERSION}" | sed 's/^v//' | cut -d. -f1,2) + +# The dynamic modules Go SDK is a sub-module within the envoy monorepo. +# Go cannot resolve sub-modules using a commit that has a root-level semver tag +# (e.g., the exact v1.37.0 tag commit), so we use the HEAD of the release branch +# (e.g., release/v1.37) which is ABI-compatible and resolvable by Go tooling. +COMMIT_SHA=$(git ls-remote https://github.com/envoyproxy/envoy \ + "refs/heads/release/v${MAJOR_MINOR}" | awk '{print $1}') +if [ -z "$COMMIT_SHA" ]; then + echo "Error: Could not find envoy release branch release/v${MAJOR_MINOR}" >&2 + exit 1 +fi + +echo "Resolved ${ENVOY_VERSION} (release/v${MAJOR_MINOR}) to commit ${COMMIT_SHA}" + +# Detect GNU sed vs BSD sed (same pattern as bump-golang.sh) +GNU_SED=$(sed --version >/dev/null 2>&1 && echo "yes" || echo "no") + +# Find all dynamic module example directories +DYNAMIC_MODULE_DIRS=$(find examples -name "go.mod" \ + -exec grep -l "envoy/source/extensions/dynamic_modules" {} \; \ + | xargs -I{} dirname {}) + +for dir in $DYNAMIC_MODULE_DIRS; do + echo "Updating ${dir}..." + + # Update Dockerfile ARG ENVOY_VERSION + if [ -f "${dir}/Dockerfile" ]; then + if [ "$GNU_SED" = "yes" ]; then + sed -i'' "s/^ARG ENVOY_VERSION=.*/ARG ENVOY_VERSION=${ENVOY_VERSION}/" "${dir}/Dockerfile" + else + sed -i '' "s/^ARG ENVOY_VERSION=.*/ARG ENVOY_VERSION=${ENVOY_VERSION}/" "${dir}/Dockerfile" + fi + fi + + # Update Makefile ENVOY_VERSION + if [ -f "${dir}/Makefile" ]; then + if [ "$GNU_SED" = "yes" ]; then + sed -i'' "s/^ENVOY_VERSION ?=.*/ENVOY_VERSION ?= ${ENVOY_VERSION}/" "${dir}/Makefile" + else + sed -i '' "s/^ENVOY_VERSION ?=.*/ENVOY_VERSION ?= ${ENVOY_VERSION}/" "${dir}/Makefile" + fi + fi + + # Update Go SDK dependency + pushd "${dir}" > /dev/null + go get "github.com/envoyproxy/envoy/source/extensions/dynamic_modules@${COMMIT_SHA}" + go mod tidy + popd > /dev/null +done + +echo "Done. Dynamic module dependencies updated to ${ENVOY_VERSION}." diff --git a/tools/make/examples.mk b/tools/make/examples.mk index 18a6f522e7..1ff30c0548 100644 --- a/tools/make/examples.mk +++ b/tools/make/examples.mk @@ -1,7 +1,11 @@ -EXAMPLE_APPS := simple-extension-server extension-server envoy-ext-auth grpc-ext-proc preserve-case-backend static-file-server +EXAMPLE_APPS := simple-extension-server extension-server envoy-ext-auth grpc-ext-proc preserve-case-backend static-file-server dynamic-module-test EXAMPLE_IMAGE_PREFIX ?= envoyproxy/gateway- EXAMPLE_TAG ?= latest +# Extract envoy proxy version from DefaultEnvoyProxyImage (e.g., "distroless-v1.37.0" -> "v1.37.0"). +# Empty on main branch where the image is "distroless-dev". +ENVOY_PROXY_VERSION := $(shell grep 'DefaultEnvoyProxyImage' api/v1alpha1/shared_types.go | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+') + kube-generate-examples: @$(LOG_TARGET) @pushd $(ROOT_DIR)/examples/extension-server; \ @@ -13,7 +17,11 @@ kube-build-examples-image: @$(LOG_TARGET) @for app in $(EXAMPLE_APPS); do \ pushd $(ROOT_DIR)/examples/$$app; \ - make docker-buildx; \ + if [ -n "$(ENVOY_PROXY_VERSION)" ]; then \ + make docker-buildx ENVOY_VERSION=$(ENVOY_PROXY_VERSION); \ + else \ + make docker-buildx; \ + fi; \ popd; \ done @@ -32,3 +40,8 @@ go.mod.tidy.examples: go mod tidy -compat=$(GO_VERSION); \ popd; \ done + +.PHONY: update-dynamic-module-deps +update-dynamic-module-deps: ## Update dynamic module SDK and envoy version in examples + @$(LOG_TARGET) + @tools/hack/bump-envoy-dynamic-modules.sh $(ENVOY_VERSION) From 11d2d45b5d00c48f9124ab73655666cbee6050cf Mon Sep 17 00:00:00 2001 From: jukie <10012479+Jukie@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:10:57 -0700 Subject: [PATCH 4/4] Fix crd install Signed-off-by: jukie <10012479+Jukie@users.noreply.github.com> --- charts/gateway-helm/.helmignore | 4 ++++ tools/make/kube.mk | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/charts/gateway-helm/.helmignore b/charts/gateway-helm/.helmignore index 9ebf1b984a..e501ad0c29 100755 --- a/charts/gateway-helm/.helmignore +++ b/charts/gateway-helm/.helmignore @@ -24,3 +24,7 @@ # Template files *.tmpl.* + +# CRDs are installed separately via gateway-crds-helm chart. +# Files remain in crds/ for go:embed (embed.go) and manifest generation. +crds/ diff --git a/tools/make/kube.mk b/tools/make/kube.mk index dc54dfd726..5a9f7295f3 100644 --- a/tools/make/kube.mk +++ b/tools/make/kube.mk @@ -406,11 +406,17 @@ generate-manifests: helm-generate.gateway-helm ## Generate Kubernetes release ma @$(LOG_TARGET) @$(call log, "Generating kubernetes manifests") mkdir -p $(OUTPUT_DIR)/ - $(GO_TOOL) helm template --set createNamespace=true eg charts/gateway-helm --include-crds --namespace envoy-gateway-system > $(OUTPUT_DIR)/install.yaml + $(GO_TOOL) helm template eg-crds charts/gateway-crds-helm \ + --set crds.gatewayAPI.enabled=true \ + --set crds.envoyGateway.enabled=true \ + > $(OUTPUT_DIR)/install.yaml + $(GO_TOOL) helm template --set createNamespace=true eg charts/gateway-helm --namespace envoy-gateway-system >> $(OUTPUT_DIR)/install.yaml @$(call log, "Added: $(OUTPUT_DIR)/install.yaml") cp examples/kubernetes/quickstart.yaml $(OUTPUT_DIR)/quickstart.yaml @$(call log, "Added: $(OUTPUT_DIR)/quickstart.yaml") - cat charts/gateway-helm/crds/generated/* >> $(OUTPUT_DIR)/envoy-gateway-crds.yaml + $(GO_TOOL) helm template eg-crds charts/gateway-crds-helm \ + --set crds.envoyGateway.enabled=true \ + > $(OUTPUT_DIR)/envoy-gateway-crds.yaml @$(call log, "Added: $(OUTPUT_DIR)/envoy-gateway-crds.yaml") .PHONY: generate-artifacts