diff --git a/api/v1alpha1/ext_proc_types.go b/api/v1alpha1/ext_proc_types.go
index 41f57e9545..857eaaa6b0 100644
--- a/api/v1alpha1/ext_proc_types.go
+++ b/api/v1alpha1/ext_proc_types.go
@@ -106,6 +106,12 @@ type ExtProc struct {
//
// +optional
Metadata *ExtProcMetadata `json:"metadata,omitempty"`
+
+ // Percentage controls what percentage of requests are sent through this ExtProc extension.
+ // When not specified, 100% of requests are sent through the extension.
+ //
+ // +optional
+ Percentage *gwapiv1.Fraction `json:"percentage,omitempty"`
}
// ExtProcMetadata defines options related to the sending and receiving of dynamic metadata to and from the
diff --git a/api/v1alpha1/lua_types.go b/api/v1alpha1/lua_types.go
index 9c93880061..1012245da4 100644
--- a/api/v1alpha1/lua_types.go
+++ b/api/v1alpha1/lua_types.go
@@ -45,4 +45,10 @@ type Lua struct {
// +optional
// +unionMember
ValueRef *gwapiv1.LocalObjectReference `json:"valueRef,omitempty"`
+
+ // Percentage controls what percentage of requests are sent through this Lua extension.
+ // When not specified, 100% of requests are sent through the extension.
+ //
+ // +optional
+ Percentage *gwapiv1.Fraction `json:"percentage,omitempty"`
}
diff --git a/api/v1alpha1/wasm_types.go b/api/v1alpha1/wasm_types.go
index d296ea1c41..74a06464a1 100644
--- a/api/v1alpha1/wasm_types.go
+++ b/api/v1alpha1/wasm_types.go
@@ -67,6 +67,12 @@ type Wasm struct {
// Env configures the environment for the Wasm extension
// +optional
Env *WasmEnv `json:"env,omitempty"`
+
+ // Percentage controls what percentage of requests are sent through this Wasm extension.
+ // When not specified, 100% of requests are sent through the extension.
+ //
+ // +optional
+ Percentage *gwapiv1.Fraction `json:"percentage,omitempty"`
}
// WasmCodeSource defines the source of the Wasm code.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 68bbe20fb4..731eeff18f 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -3321,6 +3321,11 @@ func (in *ExtProc) DeepCopyInto(out *ExtProc) {
*out = new(ExtProcMetadata)
(*in).DeepCopyInto(*out)
}
+ if in.Percentage != nil {
+ in, out := &in.Percentage, &out.Percentage
+ *out = new(v1.Fraction)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtProc.
@@ -5780,6 +5785,11 @@ func (in *Lua) DeepCopyInto(out *Lua) {
*out = new(v1.LocalObjectReference)
**out = **in
}
+ if in.Percentage != nil {
+ in, out := &in.Percentage, &out.Percentage
+ *out = new(v1.Fraction)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Lua.
@@ -8405,6 +8415,11 @@ func (in *Wasm) DeepCopyInto(out *Wasm) {
*out = new(WasmEnv)
(*in).DeepCopyInto(*out)
}
+ if in.Percentage != nil {
+ in, out := &in.Percentage, &out.Percentage
+ *out = new(v1.Fraction)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Wasm.
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 7d0413e7b8..90e3900eb2 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
@@ -1509,6 +1509,26 @@ spec:
Envoy HTTP filter namespaces
rule: self.all(f, !f.startsWith('envoy.filters.http'))
type: object
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this ExtProc extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
processingMode:
description: |-
ProcessingMode defines how request and response body is processed
@@ -1603,6 +1623,26 @@ spec:
inline:
description: Inline contains the source code as an inline string.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Lua extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
type:
default: Inline
description: |-
@@ -2109,6 +2149,26 @@ spec:
It's also used for logging/debugging.
If not specified, EG will generate a unique name for the Wasm extension.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Wasm extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
rootID:
description: |-
RootID is a unique ID for a set of extensions in a VM which will share a
diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml
index 5cf28b233d..69763cb31e 100644
--- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml
+++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_envoyextensionpolicies.yaml
@@ -1508,6 +1508,26 @@ spec:
Envoy HTTP filter namespaces
rule: self.all(f, !f.startsWith('envoy.filters.http'))
type: object
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this ExtProc extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
processingMode:
description: |-
ProcessingMode defines how request and response body is processed
@@ -1602,6 +1622,26 @@ spec:
inline:
description: Inline contains the source code as an inline string.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Lua extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
type:
default: Inline
description: |-
@@ -2108,6 +2148,26 @@ spec:
It's also used for logging/debugging.
If not specified, EG will generate a unique name for the Wasm extension.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Wasm extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
rootID:
description: |-
RootID is a unique ID for a set of extensions in a VM which will share a
diff --git a/internal/gatewayapi/envoyextensionpolicy.go b/internal/gatewayapi/envoyextensionpolicy.go
index 05ee608fb3..d9a2899d64 100644
--- a/internal/gatewayapi/envoyextensionpolicy.go
+++ b/internal/gatewayapi/envoyextensionpolicy.go
@@ -559,7 +559,8 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute(
for _, listener := range parentRefCtx.listeners {
irListener := xdsIR[irKey].GetHTTPListener(irListenerName(listener))
if irListener != nil {
- for _, r := range irListener.Routes {
+ var fallbackRoutes []*ir.HTTPRoute
+ for i, r := range irListener.Routes {
// If specified the sectionName must match route rule from ir route metadata.
if target.SectionName != nil && string(*target.SectionName) != r.Metadata.SectionName {
continue
@@ -600,9 +601,16 @@ func (t *Translator) translateEnvoyExtensionPolicyForRoute(
Luas: luas,
DynamicModules: dynamicModules,
}
+ if extensionsUsePercentage(r.EnvoyExtensions) {
+ fallback := irListener.Routes[i].DeepCopy()
+ fallback.EnvoyExtensions = fallbackExtensions(r.EnvoyExtensions)
+ fallback.Name = r.Name + "/fallback"
+ fallbackRoutes = append(fallbackRoutes, fallback)
+ }
}
}
}
+ appendFallbackRoutes(irListener, fallbackRoutes)
}
}
}
@@ -670,7 +678,8 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway(
// A Policy targeting the specific scope(xRoute rule, xRoute, Gateway listener) wins over a policy
// targeting a lesser specific scope(Gateway).
- for _, r := range http.Routes {
+ var fallbackRoutes []*ir.HTTPRoute
+ for i, r := range http.Routes {
// if already set - there's a specific level policy, so skip
if r.EnvoyExtensions != nil {
continue
@@ -703,8 +712,15 @@ func (t *Translator) translateEnvoyExtensionPolicyForGateway(
Luas: luas,
DynamicModules: dynamicModules,
}
+ if extensionsUsePercentage(r.EnvoyExtensions) {
+ fallback := http.Routes[i].DeepCopy()
+ fallback.EnvoyExtensions = fallbackExtensions(r.EnvoyExtensions)
+ fallback.Name = r.Name + "/fallback"
+ fallbackRoutes = append(fallbackRoutes, fallback)
+ }
}
}
+ appendFallbackRoutes(http, fallbackRoutes)
}
if len(routesWithDirectResponse) > 0 {
t.Logger.Info("setting 500 direct response in routes due to errors in EnvoyExtensionPolicy",
@@ -764,8 +780,9 @@ func (t *Translator) buildLua(
return nil, fmt.Errorf("validation failed for lua body in policy with name %v: %w", name, err)
}
return &ir.Lua{
- Name: name,
- Code: luaCode,
+ Name: name,
+ Code: luaCode,
+ Percentage: lua.Percentage,
}, nil
}
@@ -808,7 +825,8 @@ func (t *Translator) buildExtProcs(policy *egv1a1.EnvoyExtensionPolicy, resource
extProcIRList := make([]ir.ExtProc, 0, len(policy.Spec.ExtProc))
hasFailClose := false
- for idx, ep := range policy.Spec.ExtProc {
+ for idx := range policy.Spec.ExtProc {
+ ep := &policy.Spec.ExtProc[idx]
name := irConfigNameForExtProc(policy, idx)
extProcIR, err := t.buildExtProc(name, policy, ep, idx, resources, gtwCtx)
if err != nil {
@@ -831,7 +849,7 @@ func (t *Translator) buildExtProcs(policy *egv1a1.EnvoyExtensionPolicy, resource
func (t *Translator) buildExtProc(
name string,
policy *egv1a1.EnvoyExtensionPolicy,
- extProc egv1a1.ExtProc,
+ extProc *egv1a1.ExtProc,
extProcIdx int,
resources *resource.Resources,
gtwCtx *GatewayContext,
@@ -869,6 +887,7 @@ func (t *Translator) buildExtProc(
Destination: *rd,
Traffic: traffic,
Authority: authority,
+ Percentage: extProc.Percentage,
}
if extProc.MessageTimeout != nil {
@@ -1148,12 +1167,13 @@ func (t *Translator) buildWasm(
wasmName = *config.Name
}
wasmIR := &ir.Wasm{
- Name: name,
- RootID: config.RootID,
- WasmName: wasmName,
- Config: config.Config,
- FailOpen: failOpen,
- Code: code,
+ Name: name,
+ RootID: config.RootID,
+ WasmName: wasmName,
+ Config: config.Config,
+ FailOpen: failOpen,
+ Code: code,
+ Percentage: config.Percentage,
}
if config.Env != nil && len(config.Env.HostKeys) > 0 {
@@ -1268,6 +1288,78 @@ func (t *Translator) buildDynamicModules(
return dmIRList, errs
}
+// appendFallbackRoutes inserts each fallback route immediately after its
+// corresponding main route (identified by name prefix) so that Envoy evaluates
+// the main route (with RuntimeFraction) before the fallback.
+func appendFallbackRoutes(irListener *ir.HTTPListener, fallbacks []*ir.HTTPRoute) {
+ if len(fallbacks) == 0 {
+ return
+ }
+ fallbackByMain := make(map[string]*ir.HTTPRoute, len(fallbacks))
+ for _, fb := range fallbacks {
+ mainName := strings.TrimSuffix(fb.Name, "/fallback")
+ fallbackByMain[mainName] = fb
+ }
+
+ newRoutes := make([]*ir.HTTPRoute, 0, len(irListener.Routes)+len(fallbacks))
+ for _, r := range irListener.Routes {
+ newRoutes = append(newRoutes, r)
+ if fb, ok := fallbackByMain[r.Name]; ok {
+ newRoutes = append(newRoutes, fb)
+ }
+ }
+ irListener.Routes = newRoutes
+}
+
+func extensionsUsePercentage(exts *ir.EnvoyExtensionFeatures) bool {
+ if exts == nil {
+ return false
+ }
+ for i := range exts.ExtProcs {
+ if exts.ExtProcs[i].Percentage != nil {
+ return true
+ }
+ }
+ for i := range exts.Wasms {
+ if exts.Wasms[i].Percentage != nil {
+ return true
+ }
+ }
+ for i := range exts.Luas {
+ if exts.Luas[i].Percentage != nil {
+ return true
+ }
+ }
+ return false
+}
+
+// fallbackExtensions returns the subset of extensions that should still run on
+// requests not sampled by the main route's runtime fraction. Extensions with a
+// Percentage set are sampling-gated and must be omitted; extensions without a
+// Percentage are documented as 100% and must continue to execute on the
+// fallback route so unsampled traffic does not lose them.
+func fallbackExtensions(exts *ir.EnvoyExtensionFeatures) *ir.EnvoyExtensionFeatures {
+ out := &ir.EnvoyExtensionFeatures{
+ DynamicModules: exts.DynamicModules,
+ }
+ for i := range exts.ExtProcs {
+ if exts.ExtProcs[i].Percentage == nil {
+ out.ExtProcs = append(out.ExtProcs, exts.ExtProcs[i])
+ }
+ }
+ for i := range exts.Wasms {
+ if exts.Wasms[i].Percentage == nil {
+ out.Wasms = append(out.Wasms, exts.Wasms[i])
+ }
+ }
+ for i := range exts.Luas {
+ if exts.Luas[i].Percentage == nil {
+ out.Luas = append(out.Luas, exts.Luas[i])
+ }
+ }
+ return out
+}
+
// envoyExtensionPolicyCopiesWithStatusDeepCopy returns shallow copies with deep-copied Status fields.
// Status is mutated during translation and shares a pointer with the watchable coalesce goroutine.
func envoyExtensionPolicyCopiesWithStatusDeepCopy(policies []*egv1a1.EnvoyExtensionPolicy) []*egv1a1.EnvoyExtensionPolicy {
diff --git a/internal/gatewayapi/envoyextensionpolicy_test.go b/internal/gatewayapi/envoyextensionpolicy_test.go
index 9a29f5a863..7e962ba761 100644
--- a/internal/gatewayapi/envoyextensionpolicy_test.go
+++ b/internal/gatewayapi/envoyextensionpolicy_test.go
@@ -9,6 +9,9 @@ import (
"testing"
"github.com/stretchr/testify/assert"
+ gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+ "github.com/envoyproxy/gateway/internal/ir"
)
func Test_hasTag(t *testing.T) {
@@ -105,3 +108,91 @@ func TestValidateDynamicModuleRemoteURL(t *testing.T) {
})
}
}
+
+func TestAppendFallbackRoutes(t *testing.T) {
+ listener := &ir.HTTPListener{
+ Routes: []*ir.HTTPRoute{
+ {Name: "route-1"},
+ {Name: "route-2"},
+ },
+ }
+
+ appendFallbackRoutes(listener, []*ir.HTTPRoute{
+ {Name: "route-1/fallback"},
+ })
+
+ assert.Equal(t, []string{"route-1", "route-1/fallback", "route-2"}, []string{
+ listener.Routes[0].Name,
+ listener.Routes[1].Name,
+ listener.Routes[2].Name,
+ })
+}
+
+func TestExtensionsUsePercentage(t *testing.T) {
+ percentage := &gwapiv1.Fraction{Numerator: 50}
+
+ tests := []struct {
+ name string
+ exts *ir.EnvoyExtensionFeatures
+ want bool
+ }{
+ {
+ name: "nil extensions",
+ },
+ {
+ name: "empty extensions",
+ exts: &ir.EnvoyExtensionFeatures{},
+ },
+ {
+ name: "extproc percentage",
+ exts: &ir.EnvoyExtensionFeatures{ExtProcs: []ir.ExtProc{{Percentage: percentage}}},
+ want: true,
+ },
+ {
+ name: "wasm percentage",
+ exts: &ir.EnvoyExtensionFeatures{Wasms: []ir.Wasm{{Percentage: percentage}}},
+ want: true,
+ },
+ {
+ name: "lua percentage",
+ exts: &ir.EnvoyExtensionFeatures{Luas: []ir.Lua{{Percentage: percentage}}},
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, extensionsUsePercentage(tt.exts))
+ })
+ }
+}
+
+// TestFallbackExtensionsKeepsAlwaysOnExtensions covers the mixed-policy case:
+// when one extension is sampling-gated (Percentage set) and another is not,
+// the fallback route used by unsampled traffic must retain the always-on
+// extension so it still executes on every request.
+func TestFallbackExtensionsKeepsAlwaysOnExtensions(t *testing.T) {
+ percentage := &gwapiv1.Fraction{Numerator: 20}
+
+ exts := &ir.EnvoyExtensionFeatures{
+ ExtProcs: []ir.ExtProc{
+ {Name: "ep-always"},
+ {Name: "ep-sampled", Percentage: percentage},
+ },
+ Wasms: []ir.Wasm{
+ {Name: "wasm-sampled", Percentage: percentage},
+ },
+ Luas: []ir.Lua{
+ {Name: "lua-always"},
+ {Name: "lua-sampled", Percentage: percentage},
+ },
+ DynamicModules: []ir.DynamicModule{{Name: "dm"}},
+ }
+
+ got := fallbackExtensions(exts)
+
+ assert.Equal(t, []ir.ExtProc{{Name: "ep-always"}}, got.ExtProcs)
+ assert.Empty(t, got.Wasms)
+ assert.Equal(t, []ir.Lua{{Name: "lua-always"}}, got.Luas)
+ assert.Equal(t, []ir.DynamicModule{{Name: "dm"}}, got.DynamicModules)
+}
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index b24e7f0dc3..bf805ac27e 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -3627,6 +3627,10 @@ type ExtProc struct {
// AllowModeOverride allows the external processor to modify the processing mode.
AllowModeOverride bool `json:"allowModeOverride,omitempty" yaml:"allowModeOverride,omitempty"`
+
+ // Percentage of requests to send through this extension.
+ // nil means 100%.
+ Percentage *gwapiv1.Fraction `json:"percentage,omitempty" yaml:"percentage,omitempty"`
}
// Lua holds the information associated with Lua extensions
@@ -3636,6 +3640,10 @@ type Lua struct {
Name string
// Code is the Lua source code
Code *string
+
+ // Percentage of requests to send through this extension.
+ // nil means 100%.
+ Percentage *gwapiv1.Fraction `json:"percentage,omitempty" yaml:"percentage,omitempty"`
}
// Wasm holds the information associated with the Wasm extensions.
@@ -3669,6 +3677,10 @@ type Wasm struct {
// local HTTP server.
Code *HTTPWasmCode `json:"httpWasmCode,omitempty"`
+ // Percentage of requests to send through this extension.
+ // nil means 100%.
+ Percentage *gwapiv1.Fraction `json:"percentage,omitempty"`
+
// HostKeys is a list of keys for environment variables from the host envoy process
// that should be passed into the Wasm VM.
HostKeys []string `json:"hostKeys,omitempty"`
diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go
index 3bc828e1b4..466b4a957c 100644
--- a/internal/ir/zz_generated.deepcopy.go
+++ b/internal/ir/zz_generated.deepcopy.go
@@ -1589,6 +1589,11 @@ func (in *ExtProc) DeepCopyInto(out *ExtProc) {
*out = make([]string, len(*in))
copy(*out, *in)
}
+ if in.Percentage != nil {
+ in, out := &in.Percentage, &out.Percentage
+ *out = new(v1.Fraction)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExtProc.
@@ -3031,6 +3036,11 @@ func (in *Lua) DeepCopyInto(out *Lua) {
*out = new(string)
**out = **in
}
+ if in.Percentage != nil {
+ in, out := &in.Percentage, &out.Percentage
+ *out = new(v1.Fraction)
+ (*in).DeepCopyInto(*out)
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Lua.
@@ -5142,6 +5152,11 @@ func (in *Wasm) DeepCopyInto(out *Wasm) {
*out = new(HTTPWasmCode)
**out = **in
}
+ if in.Percentage != nil {
+ in, out := &in.Percentage, &out.Percentage
+ *out = new(v1.Fraction)
+ (*in).DeepCopyInto(*out)
+ }
if in.HostKeys != nil {
in, out := &in.HostKeys, &out.HostKeys
*out = make([]string, len(*in))
diff --git a/internal/xds/translator/extproc.go b/internal/xds/translator/extproc.go
index b31df8c60f..89074293d1 100644
--- a/internal/xds/translator/extproc.go
+++ b/internal/xds/translator/extproc.go
@@ -213,6 +213,9 @@ func (*extProc) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute, _ *ir.HT
}); err != nil {
return err
}
+ if ep.Percentage != nil {
+ applyRuntimeFractionToRouteMatch(route, ep.Percentage)
+ }
}
return nil
}
diff --git a/internal/xds/translator/lua.go b/internal/xds/translator/lua.go
index 1a05bed3da..7e9906b6ab 100644
--- a/internal/xds/translator/lua.go
+++ b/internal/xds/translator/lua.go
@@ -164,6 +164,9 @@ func (*lua) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute, _ *ir.HTTPLi
return fmt.Errorf("route already has Lua per-route config for %s", filterName)
}
route.TypedPerFilterConfig[filterName] = luaPerRouteAny
+ if ep.Percentage != nil {
+ applyRuntimeFractionToRouteMatch(route, ep.Percentage)
+ }
}
return nil
}
diff --git a/internal/xds/translator/runtime_fraction_test.go b/internal/xds/translator/runtime_fraction_test.go
new file mode 100644
index 0000000000..0780d70334
--- /dev/null
+++ b/internal/xds/translator/runtime_fraction_test.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 (
+ "testing"
+
+ corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
+ routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
+ xdstype "github.com/envoyproxy/go-control-plane/envoy/type/v3"
+ "github.com/stretchr/testify/require"
+ gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+ "github.com/envoyproxy/gateway/internal/ir"
+)
+
+func TestApplyRuntimeFractionToRouteMatch(t *testing.T) {
+ t.Run("sets runtime fraction when absent", func(t *testing.T) {
+ route := &routev3.Route{Match: &routev3.RouteMatch{}}
+
+ applyRuntimeFractionToRouteMatch(route, &gwapiv1.Fraction{
+ Numerator: 30,
+ Denominator: new(int32(100)),
+ })
+
+ rf := route.GetMatch().GetRuntimeFraction()
+ require.NotNil(t, rf)
+ require.Equal(t, uint32(30), rf.DefaultValue.Numerator)
+ require.Equal(t, xdstype.FractionalPercent_HUNDRED, rf.DefaultValue.Denominator)
+ })
+
+ t.Run("keeps tighter existing runtime fraction", func(t *testing.T) {
+ route := &routev3.Route{
+ Match: &routev3.RouteMatch{
+ RuntimeFraction: &corev3.RuntimeFractionalPercent{
+ DefaultValue: &xdstype.FractionalPercent{
+ Numerator: 100000, // 10%
+ Denominator: xdstype.FractionalPercent_MILLION,
+ },
+ },
+ },
+ }
+
+ applyRuntimeFractionToRouteMatch(route, &gwapiv1.Fraction{
+ Numerator: 20,
+ Denominator: new(int32(100)), // 20%
+ })
+
+ rf := route.GetMatch().GetRuntimeFraction()
+ require.NotNil(t, rf)
+ require.Equal(t, uint32(100000), rf.DefaultValue.Numerator)
+ require.Equal(t, xdstype.FractionalPercent_MILLION, rf.DefaultValue.Denominator)
+ })
+
+ t.Run("tightens looser existing runtime fraction", func(t *testing.T) {
+ route := &routev3.Route{
+ Match: &routev3.RouteMatch{
+ RuntimeFraction: &corev3.RuntimeFractionalPercent{
+ DefaultValue: &xdstype.FractionalPercent{
+ Numerator: 400000, // 40%
+ Denominator: xdstype.FractionalPercent_MILLION,
+ },
+ },
+ },
+ }
+
+ applyRuntimeFractionToRouteMatch(route, &gwapiv1.Fraction{
+ Numerator: 25,
+ Denominator: new(int32(100)), // 25%
+ })
+
+ rf := route.GetMatch().GetRuntimeFraction()
+ require.NotNil(t, rf)
+ require.Equal(t, uint32(25), rf.DefaultValue.Numerator)
+ require.Equal(t, xdstype.FractionalPercent_HUNDRED, rf.DefaultValue.Denominator)
+ })
+}
+
+func TestPatchRouteAppliesRuntimeFractionForExtensions(t *testing.T) {
+ testCases := []struct {
+ name string
+ patcher interface {
+ patchRoute(*routev3.Route, *ir.HTTPRoute, *ir.HTTPListener) error
+ }
+ irRoute *ir.HTTPRoute
+ }{
+ {
+ name: "extproc",
+ patcher: &extProc{},
+ irRoute: &ir.HTTPRoute{
+ EnvoyExtensions: &ir.EnvoyExtensionFeatures{
+ ExtProcs: []ir.ExtProc{{
+ Name: "ep",
+ Destination: ir.RouteDestination{
+ Name: "ep-cluster",
+ },
+ Percentage: &gwapiv1.Fraction{
+ Numerator: 12,
+ Denominator: new(int32(100)),
+ },
+ }},
+ },
+ },
+ },
+ {
+ name: "lua",
+ patcher: &lua{},
+ irRoute: &ir.HTTPRoute{
+ EnvoyExtensions: &ir.EnvoyExtensionFeatures{
+ Luas: []ir.Lua{{
+ Name: "lua",
+ Code: new("function envoy_on_request() end"),
+ Percentage: &gwapiv1.Fraction{
+ Numerator: 23,
+ Denominator: new(int32(100)),
+ },
+ }},
+ },
+ },
+ },
+ {
+ name: "wasm",
+ patcher: &wasm{},
+ irRoute: &ir.HTTPRoute{
+ EnvoyExtensions: &ir.EnvoyExtensionFeatures{
+ Wasms: []ir.Wasm{{
+ Name: "wasm",
+ Code: &ir.HTTPWasmCode{
+ ServingURL: "http://example.com/module.wasm",
+ SHA256: "abc123",
+ },
+ Percentage: &gwapiv1.Fraction{
+ Numerator: 34,
+ Denominator: new(int32(100)),
+ },
+ }},
+ },
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ route := &routev3.Route{Match: &routev3.RouteMatch{}}
+ err := tc.patcher.patchRoute(route, tc.irRoute, nil)
+ require.NoError(t, err)
+ require.NotNil(t, route.GetMatch().GetRuntimeFraction())
+ })
+ }
+}
diff --git a/internal/xds/translator/utils.go b/internal/xds/translator/utils.go
index 84ec9dd4dd..2bfec11f71 100644
--- a/internal/xds/translator/utils.go
+++ b/internal/xds/translator/utils.go
@@ -19,10 +19,12 @@ import (
hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/anypb"
+ gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1"
"github.com/envoyproxy/gateway/internal/ir"
"github.com/envoyproxy/gateway/internal/xds/types"
+ "github.com/envoyproxy/gateway/internal/xds/utils/fractionalpercent"
)
const (
@@ -125,6 +127,31 @@ func perRouteFilterName(filterType egv1a1.EnvoyFilter, configName string) string
return fmt.Sprintf("%s/%s", filterType, configName)
}
+// applyRuntimeFractionToRouteMatch sets or tightens the runtime_fraction on a route's match.
+// If the route already has a runtime_fraction (from another filter), the minimum percentage is used.
+func applyRuntimeFractionToRouteMatch(route *routev3.Route, fraction *gwapiv1.Fraction) {
+ if route == nil || route.Match == nil || fraction == nil {
+ return
+ }
+
+ fp := fractionalpercent.FromFraction(fraction)
+ if fp == nil {
+ return
+ }
+
+ normalizedNumerator := fractionalpercent.NormalizeToMillion(fp)
+ if existing := route.Match.GetRuntimeFraction(); existing != nil {
+ existingNumerator := fractionalpercent.NormalizeToMillion(existing.DefaultValue)
+ if existingNumerator < normalizedNumerator {
+ return
+ }
+ }
+
+ route.Match.RuntimeFraction = &corev3.RuntimeFractionalPercent{
+ DefaultValue: fp,
+ }
+}
+
func hcmContainsFilter(mgr *hcmv3.HttpConnectionManager, filterName string) bool {
for _, existingFilter := range mgr.HttpFilters {
if existingFilter.Name == filterName {
diff --git a/internal/xds/translator/wasm.go b/internal/xds/translator/wasm.go
index 0bbbcddd73..14be516ffd 100644
--- a/internal/xds/translator/wasm.go
+++ b/internal/xds/translator/wasm.go
@@ -194,6 +194,9 @@ func (*wasm) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute, _ *ir.HTTPL
}); err != nil {
return err
}
+ if ep.Percentage != nil {
+ applyRuntimeFractionToRouteMatch(route, ep.Percentage)
+ }
}
return nil
}
diff --git a/internal/xds/utils/fractionalpercent/fractional_percent.go b/internal/xds/utils/fractionalpercent/fractional_percent.go
index 3d18ba0a3f..0de45ff209 100644
--- a/internal/xds/utils/fractionalpercent/fractional_percent.go
+++ b/internal/xds/utils/fractionalpercent/fractional_percent.go
@@ -38,6 +38,24 @@ const (
Million = 1000000
)
+// NormalizeToMillion converts a FractionalPercent to its equivalent numerator
+// with a MILLION denominator for consistent comparison.
+func NormalizeToMillion(fp *xdstype.FractionalPercent) uint32 {
+ if fp == nil {
+ return 0
+ }
+ switch fp.Denominator {
+ case xdstype.FractionalPercent_HUNDRED:
+ return fp.Numerator * 10000
+ case xdstype.FractionalPercent_TEN_THOUSAND:
+ return fp.Numerator * 100
+ case xdstype.FractionalPercent_MILLION:
+ return fp.Numerator
+ default:
+ return fp.Numerator
+ }
+}
+
// FromFraction translates a gwapiv1.Fraction instance to envoy.type.FractionalPercent.
func FromFraction(fraction *gwapiv1.Fraction) *xdstype.FractionalPercent {
if fraction == nil {
diff --git a/internal/xds/utils/fractionalpercent/fractional_percent_test.go b/internal/xds/utils/fractionalpercent/fractional_percent_test.go
index 4206d6bca6..2e74d12eaa 100644
--- a/internal/xds/utils/fractionalpercent/fractional_percent_test.go
+++ b/internal/xds/utils/fractionalpercent/fractional_percent_test.go
@@ -51,6 +51,61 @@ func TestFromIn32(t *testing.T) {
}
}
+func TestNormalizeToMillion(t *testing.T) {
+ cases := []struct {
+ name string
+ input *xdstype.FractionalPercent
+ expected uint32
+ }{
+ {
+ name: "nil returns zero",
+ input: nil,
+ expected: 0,
+ },
+ {
+ name: "hundred denominator",
+ input: &xdstype.FractionalPercent{
+ Numerator: 7,
+ Denominator: xdstype.FractionalPercent_HUNDRED,
+ },
+ expected: 70000,
+ },
+ {
+ name: "ten thousand denominator",
+ input: &xdstype.FractionalPercent{
+ Numerator: 7,
+ Denominator: xdstype.FractionalPercent_TEN_THOUSAND,
+ },
+ expected: 700,
+ },
+ {
+ name: "million denominator",
+ input: &xdstype.FractionalPercent{
+ Numerator: 7,
+ Denominator: xdstype.FractionalPercent_MILLION,
+ },
+ expected: 7,
+ },
+ {
+ name: "unsupported denominator falls back to numerator",
+ input: &xdstype.FractionalPercent{
+ Numerator: 11,
+ Denominator: xdstype.FractionalPercent_DenominatorType(999),
+ },
+ expected: 11,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := NormalizeToMillion(tc.input)
+ if result != tc.expected {
+ t.Errorf("expected %v, got %v", tc.expected, result)
+ }
+ })
+ }
+}
+
func TestFromFloat32(t *testing.T) {
cases := []struct {
input float32
diff --git a/release-notes/current.yaml b/release-notes/current.yaml
index 6104bee389..39a68d1bdc 100644
--- a/release-notes/current.yaml
+++ b/release-notes/current.yaml
@@ -51,6 +51,7 @@ new features: |
Added support for path override in ExtAuth HTTP service.
Added support for bandwidth limit.
Added support for defining Envoy Proxy image, pullPolicy, and pullSecrets via the helm chart. Note that to merge these helm-configured values with EnvoyProxy resources, the EnvoyProxy must include `mergeType: StrategicMerge` or `mergeType: JSONMerge`.
+ Added percentage-based runtime fraction support for EnvoyExtensionPolicy Wasm, ExtProc, and Lua extensions.
bug fixes: |
Fixed local rate limit rules with identical sourceCIDR client selectors producing conflicting descriptors.
diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md
index c0be272b08..3710576e31 100644
--- a/site/content/en/latest/api/extension_types.md
+++ b/site/content/en/latest/api/extension_types.md
@@ -2260,6 +2260,7 @@ _Appears in:_
| `failOpen` | _boolean_ | false | false | FailOpen is a switch used to control the behavior when failing to call the external processor.
If FailOpen is set to true, the system bypasses the ExtProc extension and
allows the traffic to pass through. If it is set to false or
not set (defaulting to false), the system blocks the traffic and returns
an HTTP 5xx error.
If set to true, the ExtProc extension will also be bypassed if the configuration is invalid. |
| `processingMode` | _[ExtProcProcessingMode](#extprocprocessingmode)_ | false | | ProcessingMode defines how request and response body is processed
Default: header and body are not sent to the external processor |
| `metadata` | _[ExtProcMetadata](#extprocmetadata)_ | false | | Refer to Kubernetes API documentation for fields of `metadata`. |
+| `percentage` | _[Fraction](https://gateway-api.sigs.k8s.io/reference/1.5/spec/#fraction)_ | false | | Percentage controls what percentage of requests are sent through this ExtProc extension.
When not specified, 100% of requests are sent through the extension. |
#### ExtProcBodyProcessingMode
@@ -4003,6 +4004,7 @@ _Appears in:_
| `type` | _[LuaValueType](#luavaluetype)_ | true | Inline | Type is the type of method to use to read the Lua value.
Valid values are Inline and ValueRef, default is Inline. |
| `inline` | _string_ | false | | Inline contains the source code as an inline string. |
| `valueRef` | _[LocalObjectReference](#localobjectreference)_ | false | | ValueRef has the source code specified as a local object reference.
Only a reference to ConfigMap is supported.
The value of key `lua` in the ConfigMap will be used.
If the key is not found, the first value in the ConfigMap will be used. |
+| `percentage` | _[Fraction](https://gateway-api.sigs.k8s.io/reference/1.5/spec/#fraction)_ | false | | Percentage controls what percentage of requests are sent through this Lua extension.
When not specified, 100% of requests are sent through the extension. |
#### LuaValidation
@@ -6265,6 +6267,7 @@ _Appears in:_
| `config` | _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#json-v1-apiextensions-k8s-io)_ | false | | Config is the configuration for the Wasm extension.
This configuration will be passed as a JSON string to the Wasm extension. |
| `failOpen` | _boolean_ | false | false | FailOpen is a switch used to control the behavior when a fatal error occurs
during the initialization or the execution of the Wasm extension.
If FailOpen is set to true, the system bypasses the Wasm extension and
allows the traffic to pass through. If it is set to false or
not set (defaulting to false), the system blocks the traffic and returns
an HTTP 5xx error.
If set to true, the Wasm extension will also be bypassed if the configuration is invalid. |
| `env` | _[WasmEnv](#wasmenv)_ | false | | Env configures the environment for the Wasm extension |
+| `percentage` | _[Fraction](https://gateway-api.sigs.k8s.io/reference/1.5/spec/#fraction)_ | false | | Percentage controls what percentage of requests are sent through this Wasm extension.
When not specified, 100% of requests are sent through the extension. |
#### WasmCodeSource
diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml
index ff7e70b6ee..54d8935055 100644
--- a/test/helm/gateway-crds-helm/all.out.yaml
+++ b/test/helm/gateway-crds-helm/all.out.yaml
@@ -29509,6 +29509,26 @@ spec:
Envoy HTTP filter namespaces
rule: self.all(f, !f.startsWith('envoy.filters.http'))
type: object
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this ExtProc extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
processingMode:
description: |-
ProcessingMode defines how request and response body is processed
@@ -29603,6 +29623,26 @@ spec:
inline:
description: Inline contains the source code as an inline string.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Lua extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
type:
default: Inline
description: |-
@@ -30109,6 +30149,26 @@ spec:
It's also used for logging/debugging.
If not specified, EG will generate a unique name for the Wasm extension.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Wasm extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
rootID:
description: |-
RootID is a unique ID for a set of extensions in a VM which will share a
diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml
index 4f4d9eec65..afd27a8055 100644
--- a/test/helm/gateway-crds-helm/e2e.out.yaml
+++ b/test/helm/gateway-crds-helm/e2e.out.yaml
@@ -7482,6 +7482,26 @@ spec:
Envoy HTTP filter namespaces
rule: self.all(f, !f.startsWith('envoy.filters.http'))
type: object
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this ExtProc extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
processingMode:
description: |-
ProcessingMode defines how request and response body is processed
@@ -7576,6 +7596,26 @@ spec:
inline:
description: Inline contains the source code as an inline string.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Lua extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
type:
default: Inline
description: |-
@@ -8082,6 +8122,26 @@ spec:
It's also used for logging/debugging.
If not specified, EG will generate a unique name for the Wasm extension.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Wasm extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
rootID:
description: |-
RootID is a unique ID for a set of extensions in a VM which will share a
diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
index e591ca487d..953e2e53e8 100644
--- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
+++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
@@ -7482,6 +7482,26 @@ spec:
Envoy HTTP filter namespaces
rule: self.all(f, !f.startsWith('envoy.filters.http'))
type: object
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this ExtProc extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
processingMode:
description: |-
ProcessingMode defines how request and response body is processed
@@ -7576,6 +7596,26 @@ spec:
inline:
description: Inline contains the source code as an inline string.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Lua extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
type:
default: Inline
description: |-
@@ -8082,6 +8122,26 @@ spec:
It's also used for logging/debugging.
If not specified, EG will generate a unique name for the Wasm extension.
type: string
+ percentage:
+ description: |-
+ Percentage controls what percentage of requests are sent through this Wasm extension.
+ When not specified, 100% of requests are sent through the extension.
+ properties:
+ denominator:
+ default: 100
+ format: int32
+ minimum: 1
+ type: integer
+ numerator:
+ format: int32
+ minimum: 0
+ type: integer
+ required:
+ - numerator
+ type: object
+ x-kubernetes-validations:
+ - message: numerator must be less than or equal to denominator
+ rule: self.numerator <= self.denominator
rootID:
description: |-
RootID is a unique ID for a set of extensions in a VM which will share a