diff --git a/api/v1alpha1/backendtrafficpolicy_types.go b/api/v1alpha1/backendtrafficpolicy_types.go index 4e6118e7035..4183c12830f 100644 --- a/api/v1alpha1/backendtrafficpolicy_types.go +++ b/api/v1alpha1/backendtrafficpolicy_types.go @@ -74,7 +74,6 @@ type BackendTrafficPolicySpec struct { // If multiple configurations are specified, the first one to match wins. // // +optional - // +notImplementedHide ResponseOverride []*ResponseOverride `json:"responseOverride,omitempty"` } diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go index d7a2a73abe8..4bf7920f624 100644 --- a/api/v1alpha1/envoyproxy_types.go +++ b/api/v1alpha1/envoyproxy_types.go @@ -124,6 +124,8 @@ type EnvoyProxySpec struct { // // - envoy.filters.http.ratelimit // + // - envoy.filters.http.custom_response + // // - envoy.filters.http.router // // Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. @@ -174,7 +176,7 @@ type FilterPosition struct { } // EnvoyFilter defines the type of Envoy HTTP filter. -// +kubebuilder:validation:Enum=envoy.filters.http.health_check;envoy.filters.http.fault;envoy.filters.http.cors;envoy.filters.http.ext_authz;envoy.filters.http.basic_auth;envoy.filters.http.oauth2;envoy.filters.http.jwt_authn;envoy.filters.http.stateful_session;envoy.filters.http.ext_proc;envoy.filters.http.wasm;envoy.filters.http.rbac;envoy.filters.http.local_ratelimit;envoy.filters.http.ratelimit +// +kubebuilder:validation:Enum=envoy.filters.http.health_check;envoy.filters.http.fault;envoy.filters.http.cors;envoy.filters.http.ext_authz;envoy.filters.http.basic_auth;envoy.filters.http.oauth2;envoy.filters.http.jwt_authn;envoy.filters.http.stateful_session;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.custom_response type EnvoyFilter string const ( @@ -217,6 +219,9 @@ const ( // EnvoyFilterRateLimit defines the Envoy HTTP rate limit filter. EnvoyFilterRateLimit EnvoyFilter = "envoy.filters.http.ratelimit" + // EnvoyFilterCustomResponse defines the Envoy HTTP custom response filter. + EnvoyFilterCustomResponse EnvoyFilter = "envoy.filters.http.custom_response" + // EnvoyFilterRouter defines the Envoy HTTP router filter. EnvoyFilterRouter EnvoyFilter = "envoy.filters.http.router" ) diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index fe795c833db..617151e296a 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -627,33 +627,48 @@ type ResponseOverride struct { // CustomResponseMatch defines the configuration for matching a user response to return a custom one. type CustomResponseMatch struct { // Status code to match on. The match evaluates to true if any of the matches are successful. - StatusCode []StatusCodeMatch `json:"statusCode"` + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=50 + StatusCodes []StatusCodeMatch `json:"statusCodes"` } // StatusCodeValueType defines the types of values for the status code match supported by Envoy Gateway. // +kubebuilder:validation:Enum=Value;Range type StatusCodeValueType string +const ( + // StatusCodeValueTypeValue defines the "Value" status code match type. + StatusCodeValueTypeValue StatusCodeValueType = "Value" + + // StatusCodeValueTypeRange defines the "Range" status code match type. + StatusCodeValueTypeRange StatusCodeValueType = "Range" +) + +// StatusCodeMatch defines the configuration for matching a status code. +// +kubebuilder:validation:XValidation:message="value must be set for type Value",rule="(!has(self.type) || self.type == 'Value')? has(self.value) : true" +// +kubebuilder:validation:XValidation:message="range must be set for type Range",rule="(has(self.type) && self.type == 'Range')? has(self.range) : true" type StatusCodeMatch struct { // Type is the type of value. + // Valid values are Value and Range, default is Value. // // +kubebuilder:default=Value + // +kubebuilder:validation:Enum=Value;Range // +unionDiscriminator Type *StatusCodeValueType `json:"type"` // Value contains the value of the status code. // // +optional - Value *string `json:"value,omitempty"` - // ValueRef contains the contents of the body - // specified as a local object reference. - // Only a reference to ConfigMap is supported. + Value *int `json:"value,omitempty"` + + // Range contains the range of status codes. // // +optional Range *StatusCodeRange `json:"range,omitempty"` } // StatusCodeRange defines the configuration for define a range of status codes. +// +kubebuilder:validation:XValidation: message="end must be greater than start",rule="self.end > self.start" type StatusCodeRange struct { // Start of the range, including the start value. Start int `json:"start"` @@ -669,19 +684,31 @@ type CustomResponse struct { ContentType *string `json:"contentType,omitempty"` // Body of the Custom Response - // - // +optional - Body *CustomResponseBody `json:"body,omitempty"` + Body CustomResponseBody `json:"body"` } // ResponseValueType defines the types of values for the response body supported by Envoy Gateway. // +kubebuilder:validation:Enum=Inline;ValueRef type ResponseValueType string +const ( + // ResponseValueTypeInline defines the "Inline" response body type. + ResponseValueTypeInline ResponseValueType = "Inline" + + // ResponseValueTypeValueRef defines the "ValueRef" response body type. + ResponseValueTypeValueRef ResponseValueType = "ValueRef" +) + // CustomResponseBody +// +kubebuilder:validation:XValidation:message="inline must be set for type Inline",rule="(!has(self.type) || self.type == 'Inline')? has(self.inline) : true" +// +kubebuilder:validation:XValidation:message="valueRef must be set for type ValueRef",rule="(has(self.type) && self.type == 'ValueRef')? has(self.valueRef) : true" +// +kubebuilder:validation:XValidation:message="only ConfigMap is supported for ValueRef",rule="has(self.valueRef) ? self.valueRef.kind == 'ConfigMap' : true" type CustomResponseBody struct { // Type is the type of method to use to read the body value. + // Valid values are Inline and ValueRef, default is Inline. // + // +kubebuilder:default=Inline + // +kubebuilder:validation:Enum=Inline;ValueRef // +unionDiscriminator Type *ResponseValueType `json:"type"` @@ -689,10 +716,14 @@ type CustomResponseBody struct { // // +optional Inline *string `json:"inline,omitempty"` + // ValueRef contains the contents of the body // specified as a local object reference. // Only a reference to ConfigMap is supported. // + // The value of key `response.body` in the ConfigMap will be used as the response body. + // If the key is not found, the first value in the ConfigMap will be used. + // // +optional ValueRef *gwapiv1.LocalObjectReference `json:"valueRef,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 696c99259fb..c225d65d39e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1148,11 +1148,7 @@ func (in *CustomResponse) DeepCopyInto(out *CustomResponse) { *out = new(string) **out = **in } - if in.Body != nil { - in, out := &in.Body, &out.Body - *out = new(CustomResponseBody) - (*in).DeepCopyInto(*out) - } + in.Body.DeepCopyInto(&out.Body) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponse. @@ -1198,8 +1194,8 @@ func (in *CustomResponseBody) DeepCopy() *CustomResponseBody { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { *out = *in - if in.StatusCode != nil { - in, out := &in.StatusCode, &out.StatusCode + if in.StatusCodes != nil { + in, out := &in.StatusCodes, &out.StatusCodes *out = make([]StatusCodeMatch, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) @@ -5166,7 +5162,7 @@ func (in *StatusCodeMatch) DeepCopyInto(out *StatusCodeMatch) { } if in.Value != nil { in, out := &in.Value, &out.Value - *out = new(string) + *out = new(int) **out = **in } if in.Range != nil { diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 7b2e937312d..f9fb0f329dd 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -940,16 +940,15 @@ spec: match: description: Match configuration. properties: - statusCode: + statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. items: + description: StatusCodeMatch defines the configuration + for matching a status code. properties: range: - description: |- - ValueRef contains the contents of the body - specified as a local object reference. - Only a reference to ConfigMap is supported. + description: Range contains the range of status codes. properties: end: description: End of the range, including the end @@ -963,23 +962,41 @@ spec: - end - start type: object + x-kubernetes-validations: + - message: end must be greater than start + rule: self.end > self.start type: + allOf: + - enum: + - Value + - Range + - enum: + - Value + - Range default: Value - description: Type is the type of value. - enum: - - Value - - Range + description: |- + Type is the type of value. + Valid values are Value and Range, default is Value. type: string value: description: Value contains the value of the status code. - type: string + type: integer required: - type type: object + x-kubernetes-validations: + - message: value must be set for type Value + rule: '(!has(self.type) || self.type == ''Value'')? + has(self.value) : true' + - message: range must be set for type Range + rule: '(has(self.type) && self.type == ''Range'')? has(self.range) + : true' + maxItems: 50 + minItems: 1 type: array required: - - statusCode + - statusCodes type: object response: description: Response configuration. @@ -992,17 +1009,26 @@ spec: string. type: string type: - description: Type is the type of method to use to read - the body value. - enum: - - Inline - - ValueRef + allOf: + - enum: + - Inline + - ValueRef + - enum: + - Inline + - ValueRef + default: Inline + description: |- + Type is the type of method to use to read the body value. + Valid values are Inline and ValueRef, default is Inline. type: string valueRef: description: |- ValueRef contains the contents of the body specified as a local object reference. Only a reference to ConfigMap is supported. + + The value of key `response.body` in the ConfigMap will be used as the response body. + If the key is not found, the first value in the ConfigMap will be used. properties: group: description: |- @@ -1031,10 +1057,22 @@ spec: required: - type type: object + x-kubernetes-validations: + - message: inline must be set for type Inline + rule: '(!has(self.type) || self.type == ''Inline'')? has(self.inline) + : true' + - message: valueRef must be set for type ValueRef + rule: '(has(self.type) && self.type == ''ValueRef'')? + has(self.valueRef) : true' + - message: only ConfigMap is supported for ValueRef + rule: 'has(self.valueRef) ? self.valueRef.kind == ''ConfigMap'' + : true' contentType: description: Content Type of the response. This will be set in the Content-Type header. type: string + required: + - body type: object required: - match 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 5b0130f2736..0733ed112b6 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml @@ -305,6 +305,8 @@ spec: - envoy.filters.http.ratelimit + - envoy.filters.http.custom_response + - envoy.filters.http.router Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. @@ -330,6 +332,7 @@ spec: - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit + - envoy.filters.http.custom_response type: string before: description: |- @@ -349,6 +352,7 @@ spec: - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit + - envoy.filters.http.custom_response type: string name: description: Name of the filter. @@ -366,6 +370,7 @@ spec: - envoy.filters.http.rbac - envoy.filters.http.local_ratelimit - envoy.filters.http.ratelimit + - envoy.filters.http.custom_response type: string required: - name diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml index 8a75fec4211..672cfb59df8 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_httproutefilters.yaml @@ -60,17 +60,26 @@ spec: description: Inline contains the value as an inline string. type: string type: - description: Type is the type of method to use to read the - body value. - enum: - - Inline - - ValueRef + allOf: + - enum: + - Inline + - ValueRef + - enum: + - Inline + - ValueRef + default: Inline + description: |- + Type is the type of method to use to read the body value. + Valid values are Inline and ValueRef, default is Inline. type: string valueRef: description: |- ValueRef contains the contents of the body specified as a local object reference. Only a reference to ConfigMap is supported. + + The value of key `response.body` in the ConfigMap will be used as the response body. + If the key is not found, the first value in the ConfigMap will be used. properties: group: description: |- @@ -99,6 +108,16 @@ spec: required: - type type: object + x-kubernetes-validations: + - message: inline must be set for type Inline + rule: '(!has(self.type) || self.type == ''Inline'')? has(self.inline) + : true' + - message: valueRef must be set for type ValueRef + rule: '(has(self.type) && self.type == ''ValueRef'')? has(self.valueRef) + : true' + - message: only ConfigMap is supported for ValueRef + rule: 'has(self.valueRef) ? self.valueRef.kind == ''ConfigMap'' + : true' contentType: description: Content Type of the response. This will be set in the Content-Type header. diff --git a/go.mod b/go.mod index 4090b698e5e..a10aa68bbac 100644 --- a/go.mod +++ b/go.mod @@ -281,7 +281,7 @@ require ( golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.24.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index b8f289a9df0..89b6804a2ba 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -10,9 +10,11 @@ import ( "fmt" "math" "sort" + "strconv" "strings" perr "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" @@ -34,6 +36,7 @@ func (t *Translator) ProcessBackendTrafficPolicies(backendTrafficPolicies []*egv gateways []*GatewayContext, routes []RouteContext, xdsIR resource.XdsIRMap, + configMaps []*corev1.ConfigMap, ) []*egv1a1.BackendTrafficPolicy { res := []*egv1a1.BackendTrafficPolicy{} @@ -127,7 +130,7 @@ func (t *Translator) ProcessBackendTrafficPolicies(backendTrafficPolicies []*egv } // Set conditions for translation error if it got any - if err := t.translateBackendTrafficPolicyForRoute(policy, route, xdsIR); err != nil { + if err := t.translateBackendTrafficPolicyForRoute(policy, route, xdsIR, configMaps); err != nil { status.SetTranslationErrorForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, @@ -181,7 +184,7 @@ func (t *Translator) ProcessBackendTrafficPolicies(backendTrafficPolicies []*egv } // Set conditions for translation error if it got any - if err := t.translateBackendTrafficPolicyForGateway(policy, currTarget, gateway, xdsIR); err != nil { + if err := t.translateBackendTrafficPolicyForGateway(policy, currTarget, gateway, xdsIR, configMaps); err != nil { status.SetTranslationErrorForPolicyAncestors(&policy.Status, ancestorRefs, t.GatewayControllerName, @@ -281,7 +284,12 @@ func resolveBTPolicyRouteTargetRef(policy *egv1a1.BackendTrafficPolicy, target g return route.RouteContext, nil } -func (t *Translator) translateBackendTrafficPolicyForRoute(policy *egv1a1.BackendTrafficPolicy, route RouteContext, xdsIR resource.XdsIRMap) error { +func (t *Translator) translateBackendTrafficPolicyForRoute( + policy *egv1a1.BackendTrafficPolicy, + route RouteContext, + xdsIR resource.XdsIRMap, + configMaps []*corev1.ConfigMap, +) error { var ( rl *ir.RateLimit lb *ir.LoadBalancer @@ -295,6 +303,7 @@ func (t *Translator) translateBackendTrafficPolicyForRoute(policy *egv1a1.Backen bc *ir.BackendConnection ds *ir.DNS h2 *ir.HTTP2Settings + ro *ir.ResponseOverride err, errs error ) @@ -340,6 +349,11 @@ func (t *Translator) translateBackendTrafficPolicyForRoute(policy *egv1a1.Backen errs = errors.Join(errs, err) } + if ro, err = buildResponseOverride(policy, configMaps); err != nil { + err = perr.WithMessage(err, "ResponseOverride") + errs = errors.Join(errs, err) + } + ds = translateDNS(policy.Spec.ClusterSettings) // Apply IR to all relevant routes @@ -402,6 +416,7 @@ func (t *Translator) translateBackendTrafficPolicyForRoute(policy *egv1a1.Backen HTTP2: h2, DNS: ds, Timeout: to, + ResponseOverride: ro, } // Update the Host field in HealthCheck, now that we have access to the Route Hostname. @@ -418,7 +433,13 @@ func (t *Translator) translateBackendTrafficPolicyForRoute(policy *egv1a1.Backen return errs } -func (t *Translator) translateBackendTrafficPolicyForGateway(policy *egv1a1.BackendTrafficPolicy, target gwapiv1a2.LocalPolicyTargetReferenceWithSectionName, gateway *GatewayContext, xdsIR resource.XdsIRMap) error { +func (t *Translator) translateBackendTrafficPolicyForGateway( + policy *egv1a1.BackendTrafficPolicy, + target gwapiv1a2.LocalPolicyTargetReferenceWithSectionName, + gateway *GatewayContext, + xdsIR resource.XdsIRMap, + configMaps []*corev1.ConfigMap, +) error { var ( rl *ir.RateLimit lb *ir.LoadBalancer @@ -431,6 +452,7 @@ func (t *Translator) translateBackendTrafficPolicyForGateway(policy *egv1a1.Back rt *ir.Retry ds *ir.DNS h2 *ir.HTTP2Settings + ro *ir.ResponseOverride err, errs error ) @@ -469,6 +491,10 @@ func (t *Translator) translateBackendTrafficPolicyForGateway(policy *egv1a1.Back err = perr.WithMessage(err, "HTTP2") errs = errors.Join(errs, err) } + if ro, err = buildResponseOverride(policy, configMaps); err != nil { + err = perr.WithMessage(err, "ResponseOverride") + errs = errors.Join(errs, err) + } ds = translateDNS(policy.Spec.ClusterSettings) @@ -542,16 +568,17 @@ func (t *Translator) translateBackendTrafficPolicyForGateway(policy *egv1a1.Back } r.Traffic = &ir.TrafficFeatures{ - RateLimit: rl, - LoadBalancer: lb, - ProxyProtocol: pp, - HealthCheck: hc, - CircuitBreaker: cb, - FaultInjection: fi, - TCPKeepalive: ka, - Retry: rt, - HTTP2: h2, - DNS: ds, + RateLimit: rl, + LoadBalancer: lb, + ProxyProtocol: pp, + HealthCheck: hc, + CircuitBreaker: cb, + FaultInjection: fi, + TCPKeepalive: ka, + Retry: rt, + HTTP2: h2, + DNS: ds, + ResponseOverride: ro, } // Update the Host field in HealthCheck, now that we have access to the Route Hostname. @@ -836,3 +863,81 @@ func makeIrTriggerSet(in []egv1a1.TriggerEnum) []ir.TriggerEnum { } return irTriggers } + +func buildResponseOverride(policy *egv1a1.BackendTrafficPolicy, configMaps []*corev1.ConfigMap) (*ir.ResponseOverride, error) { + if len(policy.Spec.ResponseOverride) == 0 { + return nil, nil + } + + rules := make([]ir.ResponseOverrideRule, 0, len(policy.Spec.ResponseOverride)) + for index, ro := range policy.Spec.ResponseOverride { + match := ir.CustomResponseMatch{ + StatusCodes: make([]ir.StatusCodeMatch, 0, len(ro.Match.StatusCodes)), + } + + for _, code := range ro.Match.StatusCodes { + if code.Type != nil && *code.Type == egv1a1.StatusCodeValueTypeRange { + match.StatusCodes = append(match.StatusCodes, ir.StatusCodeMatch{ + Range: &ir.StatusCodeRange{ + Start: code.Range.Start, + End: code.Range.End, + }, + }) + } else { + match.StatusCodes = append(match.StatusCodes, ir.StatusCodeMatch{ + Value: code.Value, + }) + } + } + + response := ir.CustomResponse{ + ContentType: ro.Response.ContentType, + } + + if ro.Response.Body.Type != nil && *ro.Response.Body.Type == egv1a1.ResponseValueTypeValueRef { + foundCM := false + for _, cm := range configMaps { + if cm.Namespace == policy.Namespace && cm.Name == string(ro.Response.Body.ValueRef.Name) { + body, dataOk := cm.Data["response.body"] + switch { + case dataOk: + response.Body = body + case len(cm.Data) > 0: // Fallback to the first key if response.body is not found + for _, value := range cm.Data { + body = value + break + } + response.Body = body + default: + return nil, fmt.Errorf("can't find the key response.body in the referenced configmap %s", ro.Response.Body.ValueRef.Name) + } + + foundCM = true + break + } + } + if !foundCM { + return nil, fmt.Errorf("can't find the referenced configmap %s", ro.Response.Body.ValueRef.Name) + } + } else { + response.Body = *ro.Response.Body.Inline + } + + rules = append(rules, ir.ResponseOverrideRule{ + Name: defaultResponseOverrideRuleName(policy, index), + Match: match, + Response: response, + }) + } + return &ir.ResponseOverride{ + Name: irConfigName(policy), + Rules: rules, + }, nil +} + +func defaultResponseOverrideRuleName(policy *egv1a1.BackendTrafficPolicy, index int) string { + return fmt.Sprintf( + "%s/responseoverride/rule/%s", + irConfigName(policy), + strconv.Itoa(index)) +} diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-invalid-valueref.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-invalid-valueref.in.yaml new file mode 100644 index 00000000000..e44a8473d5c --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-invalid-valueref.in.yaml @@ -0,0 +1,141 @@ +gateways: + - apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: default + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All + - apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: default + name: gateway-2 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +grpcRoutes: + - apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: GRPCRoute + metadata: + namespace: default + name: grpcroute-1 + spec: + parentRefs: + - namespace: default + name: gateway-1 + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 +httpRoutes: + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: default + name: gateway-2 + sectionName: http + rules: + - matches: + - path: + value: "/" + backendRefs: + - name: service-1 + port: 8080 +configMaps: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: response-override-config + namespace: default + data: {} +backendTrafficPolicies: + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + namespace: default + name: policy-for-gateway + spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + responseOverride: + - match: + statusCodes: + - type: Value + value: 404 + response: + contentType: text/plain + body: + type: Inline + inline: "gateway-1 Not Found" + - match: + statusCodes: + - type: Value + value: 500 + - type: Range + range: + start: 501 + end: 511 + response: + contentType: application/json + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + namespace: default + name: policy-for-route + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + responseOverride: + - match: + statusCodes: + - value: 404 + response: + contentType: text/plain + body: + inline: "httproute-1 Not Found" + - match: + statusCodes: + - value: 500 + - type: Range + range: + start: 501 + end: 511 + response: + contentType: application/json + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config-1 diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-invalid-valueref.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-invalid-valueref.out.yaml new file mode 100644 index 00000000000..c1542d9caec --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-invalid-valueref.out.yaml @@ -0,0 +1,371 @@ +backendTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + creationTimestamp: null + name: policy-for-route + namespace: default + spec: + responseOverride: + - match: + statusCodes: + - type: null + value: 404 + response: + body: + inline: httproute-1 Not Found + type: null + contentType: text/plain + - match: + statusCodes: + - type: null + value: 500 + - range: + end: 511 + start: 501 + type: Range + response: + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config-1 + contentType: application/json + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-2 + namespace: default + sectionName: http + conditions: + - lastTransitionTime: null + message: 'ResponseOverride: can''t find the referenced configmap response-override-config-1.' + reason: Invalid + status: "False" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + creationTimestamp: null + name: policy-for-gateway + namespace: default + spec: + responseOverride: + - match: + statusCodes: + - type: Value + value: 404 + response: + body: + inline: gateway-1 Not Found + type: Inline + contentType: text/plain + - match: + statusCodes: + - type: Value + value: 500 + - range: + end: 511 + start: 501 + type: Range + response: + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config + contentType: application/json + 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: default + conditions: + - lastTransitionTime: null + message: 'ResponseOverride: can''t find the key response.body in the referenced + configmap response-override-config.' + reason: Invalid + status: "False" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: default + spec: + gatewayClassName: envoy-gateway-class + 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 +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-2 + namespace: default + spec: + gatewayClassName: envoy-gateway-class + 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 +grpcRoutes: +- apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: GRPCRoute + metadata: + creationTimestamp: null + name: grpcroute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: default + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + 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: default + sectionName: http +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-2 + namespace: default + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: / + 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-2 + namespace: default + sectionName: http +infraIR: + default/gateway-1: + proxy: + listeners: + - address: null + name: default/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: default + name: default/gateway-1 + default/gateway-2: + proxy: + listeners: + - address: null + name: default/gateway-2/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-2 + gateway.envoyproxy.io/owning-gateway-namespace: default + name: default/gateway-2 +xdsIR: + default/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: true + metadata: + kind: Gateway + name: gateway-1 + namespace: default + sectionName: http + name: default/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + name: grpcroute/default/grpcroute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: GRPC + weight: 1 + directResponse: + statusCode: 500 + hostname: '*' + isHTTP2: true + metadata: + kind: GRPCRoute + name: grpcroute-1 + namespace: default + name: grpcroute/default/grpcroute-1/rule/0/match/-1/* + default/gateway-2: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-2 + namespace: default + sectionName: http + name: default/gateway-2/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 + directResponse: + statusCode: 500 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml new file mode 100644 index 00000000000..51dd9fd7114 --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml @@ -0,0 +1,145 @@ +gateways: + - apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: default + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All + - apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: default + name: gateway-2 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + allowedRoutes: + namespaces: + from: All +grpcRoutes: + - apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: GRPCRoute + metadata: + namespace: default + name: grpcroute-1 + spec: + parentRefs: + - namespace: default + name: gateway-1 + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 +httpRoutes: + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: default + name: gateway-2 + sectionName: http + rules: + - matches: + - path: + value: "/" + backendRefs: + - name: service-1 + port: 8080 +configMaps: + - apiVersion: v1 + kind: ConfigMap + metadata: + name: response-override-config + namespace: default + data: + response.body: | + { + "error": "Internal Server Error" + } +backendTrafficPolicies: + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + namespace: default + name: policy-for-gateway + spec: + targetRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + responseOverride: + - match: + statusCodes: + - type: Value + value: 404 + response: + contentType: text/plain + body: + type: Inline + inline: "gateway-1 Not Found" + - match: + statusCodes: + - type: Value + value: 500 + - type: Range + range: + start: 501 + end: 511 + response: + contentType: application/json + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + namespace: default + name: policy-for-route + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + responseOverride: + - match: + statusCodes: + - value: 404 + response: + contentType: text/plain + body: + inline: "httproute-1 Not Found" + - match: + statusCodes: + - value: 500 + - type: Range + range: + start: 501 + end: 511 + response: + contentType: application/json + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml new file mode 100644 index 00000000000..568a57af484 --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml @@ -0,0 +1,414 @@ +backendTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + creationTimestamp: null + name: policy-for-route + namespace: default + spec: + responseOverride: + - match: + statusCodes: + - type: null + value: 404 + response: + body: + inline: httproute-1 Not Found + type: null + contentType: text/plain + - match: + statusCodes: + - type: null + value: 500 + - range: + end: 511 + start: 501 + type: Range + response: + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config + contentType: application/json + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-2 + namespace: default + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + creationTimestamp: null + name: policy-for-gateway + namespace: default + spec: + responseOverride: + - match: + statusCodes: + - type: Value + value: 404 + response: + body: + inline: gateway-1 Not Found + type: Inline + contentType: text/plain + - match: + statusCodes: + - type: Value + value: 500 + - range: + end: 511 + start: 501 + type: Range + response: + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config + contentType: application/json + 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: default + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: default + spec: + gatewayClassName: envoy-gateway-class + 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 +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-2 + namespace: default + spec: + gatewayClassName: envoy-gateway-class + 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 +grpcRoutes: +- apiVersion: gateway.networking.k8s.io/v1alpha2 + kind: GRPCRoute + metadata: + creationTimestamp: null + name: grpcroute-1 + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: default + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + 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: default + sectionName: http +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-2 + namespace: default + sectionName: http + rules: + - backendRefs: + - name: service-1 + port: 8080 + matches: + - path: + value: / + 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-2 + namespace: default + sectionName: http +infraIR: + default/gateway-1: + proxy: + listeners: + - address: null + name: default/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: default + name: default/gateway-1 + default/gateway-2: + proxy: + listeners: + - address: null + name: default/gateway-2/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-2 + gateway.envoyproxy.io/owning-gateway-namespace: default + name: default/gateway-2 +xdsIR: + default/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: true + metadata: + kind: Gateway + name: gateway-1 + namespace: default + sectionName: http + name: default/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + name: grpcroute/default/grpcroute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: GRPC + weight: 1 + hostname: '*' + isHTTP2: true + metadata: + kind: GRPCRoute + name: grpcroute-1 + namespace: default + name: grpcroute/default/grpcroute-1/rule/0/match/-1/* + traffic: + responseOverride: + name: backendtrafficpolicy/default/policy-for-gateway + rules: + - match: + statusCodes: + - value: 404 + name: backendtrafficpolicy/default/policy-for-gateway/responseoverride/rule/0 + response: + body: gateway-1 Not Found + contentType: text/plain + - match: + statusCodes: + - value: 500 + - range: + end: 511 + start: 501 + name: backendtrafficpolicy/default/policy-for-gateway/responseoverride/rule/1 + response: + body: | + { + "error": "Internal Server Error" + } + contentType: application/json + default/gateway-2: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-2 + namespace: default + sectionName: http + name: default/gateway-2/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 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / + traffic: + responseOverride: + name: backendtrafficpolicy/default/policy-for-route + rules: + - match: + statusCodes: + - value: 404 + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/0 + response: + body: httproute-1 Not Found + contentType: text/plain + - match: + statusCodes: + - value: 500 + - range: + end: 511 + start: 501 + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/1 + response: + body: | + { + "error": "Internal Server Error" + } + contentType: application/json diff --git a/internal/gatewayapi/translator.go b/internal/gatewayapi/translator.go index 0e6d683d855..0f518b71033 100644 --- a/internal/gatewayapi/translator.go +++ b/internal/gatewayapi/translator.go @@ -211,7 +211,7 @@ func (t *Translator) Translate(resources *resource.Resources) (*TranslateResult, // Process BackendTrafficPolicies backendTrafficPolicies := t.ProcessBackendTrafficPolicies( - resources.BackendTrafficPolicies, gateways, routes, xdsIR) + resources.BackendTrafficPolicies, gateways, routes, xdsIR, resources.ConfigMaps) // Process SecurityPolicies securityPolicies := t.ProcessSecurityPolicies( diff --git a/internal/ir/xds.go b/internal/ir/xds.go index fdcace324f5..cb5021f4c9f 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -477,6 +477,63 @@ type HTTP2Settings struct { ResetStreamOnError *bool `json:"resetStreamOnError,omitempty" yaml:"resetStreamOnError,omitempty"` } +// ResponseOverride defines the configuration to override specific responses with a custom one. +// +k8s:deepcopy-gen=true +type ResponseOverride struct { + // Name is a unique name for a ResponseOverride configuration. + // The xds translator only generates one CustomResponse filter for each unique name. + Name string `json:"name" yaml:"name"` + + // Rules contains the list of rules to override responses. + Rules []ResponseOverrideRule `json:"rules,omitempty"` +} + +// ResponseOverrideRule defines the configuration for overriding a response. +// +k8s:deepcopy-gen=true +type ResponseOverrideRule struct { + // Name is a generated name for the rule. + Name string `json:"name"` + // Match configuration. + Match CustomResponseMatch `json:"match"` + // Response configuration. + Response CustomResponse `json:"response"` +} + +// CustomResponseMatch defines the configuration for matching a user response to return a custom one. +// +k8s:deepcopy-gen=true +type CustomResponseMatch struct { + // Status code to match on. The match evaluates to true if any of the matches are successful. + StatusCodes []StatusCodeMatch `json:"statusCodes"` +} + +// StatusCodeMatch defines the configuration for matching a status code. +// +k8s:deepcopy-gen=true +type StatusCodeMatch struct { + // Value contains the value of the status code. + Value *int `json:"value,omitempty"` + + // Range contains a range of status codes. + Range *StatusCodeRange `json:"range,omitempty"` +} + +// StatusCodeRange defines the configuration for define a range of status codes. +type StatusCodeRange struct { + // Start of the range, including the start value. + Start int `json:"start"` + // End of the range, including the end value. + End int `json:"end"` +} + +// CustomResponse defines the configuration for returning a custom response. +// +k8s:deepcopy-gen=true +type CustomResponse struct { + // Content Type of the response. This will be set in the Content-Type header. + ContentType *string `json:"contentType,omitempty"` + + // Body of the Custom Response + Body string `json:"body"` +} + // HealthCheckSettings provides HealthCheck configuration on the HTTP/HTTPS listener. // +k8s:deepcopy-gen=true type HealthCheckSettings egv1a1.HealthCheckSettings @@ -657,6 +714,8 @@ type TrafficFeatures struct { HTTP2 *HTTP2Settings `json:"http2,omitempty" yaml:"http2,omitempty"` // DNS is used to configure how DNS resolution is handled by the Envoy Proxy cluster DNS *DNS `json:"dns,omitempty" yaml:"dns,omitempty"` + // ResponseOverride defines the schema for overriding the response. + ResponseOverride *ResponseOverride `json:"responseOverride,omitempty" yaml:"responseOverride,omitempty"` } func (b *TrafficFeatures) Validate() error { diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 3c0c1135f44..791b6d5dd68 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -602,6 +602,48 @@ func (in *CoreListenerDetails) DeepCopy() *CoreListenerDetails { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomResponse) DeepCopyInto(out *CustomResponse) { + *out = *in + if in.ContentType != nil { + in, out := &in.ContentType, &out.ContentType + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponse. +func (in *CustomResponse) DeepCopy() *CustomResponse { + if in == nil { + return nil + } + out := new(CustomResponse) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { + *out = *in + if in.StatusCodes != nil { + in, out := &in.StatusCodes, &out.StatusCodes + *out = make([]StatusCodeMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponseMatch. +func (in *CustomResponseMatch) DeepCopy() *CustomResponseMatch { + if in == nil { + return nil + } + out := new(CustomResponseMatch) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNS) DeepCopyInto(out *DNS) { *out = *in @@ -2399,6 +2441,45 @@ func (in *ResourceMetadata) DeepCopy() *ResourceMetadata { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverride) DeepCopyInto(out *ResponseOverride) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]ResponseOverrideRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverride. +func (in *ResponseOverride) DeepCopy() *ResponseOverride { + if in == nil { + return nil + } + out := new(ResponseOverride) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverrideRule) DeepCopyInto(out *ResponseOverrideRule) { + *out = *in + in.Match.DeepCopyInto(&out.Match) + in.Response.DeepCopyInto(&out.Response) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideRule. +func (in *ResponseOverrideRule) DeepCopy() *ResponseOverrideRule { + if in == nil { + return nil + } + out := new(ResponseOverrideRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Retry) DeepCopyInto(out *Retry) { *out = *in @@ -2590,6 +2671,31 @@ func (in *SlowStart) DeepCopy() *SlowStart { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StatusCodeMatch) DeepCopyInto(out *StatusCodeMatch) { + *out = *in + if in.Value != nil { + in, out := &in.Value, &out.Value + *out = new(int) + **out = **in + } + if in.Range != nil { + in, out := &in.Range, &out.Range + *out = new(StatusCodeRange) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusCodeMatch. +func (in *StatusCodeMatch) DeepCopy() *StatusCodeMatch { + if in == nil { + return nil + } + out := new(StatusCodeMatch) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StringMatch) DeepCopyInto(out *StringMatch) { *out = *in @@ -3159,6 +3265,11 @@ func (in *TrafficFeatures) DeepCopyInto(out *TrafficFeatures) { *out = new(DNS) (*in).DeepCopyInto(*out) } + if in.ResponseOverride != nil { + in, out := &in.ResponseOverride, &out.ResponseOverride + *out = new(ResponseOverride) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrafficFeatures. diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go index 7fe3c3d32ff..de020dfcee8 100644 --- a/internal/provider/kubernetes/controller.go +++ b/internal/provider/kubernetes/controller.go @@ -239,7 +239,7 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques } // Add all BackendTrafficPolicies to the resourceTree - if err = r.processBackendTrafficPolicies(ctx, gwcResource); err != nil { + if err = r.processBackendTrafficPolicies(ctx, gwcResource, resourceMappings); err != nil { return reconcile.Result{}, err } @@ -748,6 +748,39 @@ func (r *gatewayAPIReconciler) processConfigMapRef( return nil } +// processBtpConfigMapRefs adds the referenced ConfigMaps in BackendTrafficPolicies +// to the resourceTree +func (r *gatewayAPIReconciler) processBtpConfigMapRefs( + ctx context.Context, resourceTree *resource.Resources, resourceMap *resourceMappings, +) { + for _, policy := range resourceTree.BackendTrafficPolicies { + for _, ro := range policy.Spec.ResponseOverride { + if ro.Response.Body.ValueRef != nil && string(ro.Response.Body.ValueRef.Kind) == resource.KindConfigMap { + configMap := new(corev1.ConfigMap) + err := r.client.Get(ctx, + types.NamespacedName{Namespace: policy.Namespace, Name: string(ro.Response.Body.ValueRef.Name)}, + configMap, + ) + // we don't return an error here, because we want to continue + // reconciling the rest of the BackendTrafficPolicies despite that this + // reference is invalid. + // This BackendTrafficPolicies will be marked as invalid in its status + // when translating to IR because the referenced configmap can't be + // found. + if err != nil { + r.log.Error(err, + "failed to process ResponseOverride ValueRef for BackendTrafficPolicy", + "policy", policy, "ValueRef", ro.Response.Body.ValueRef.Name) + } + + resourceMap.allAssociatedNamespaces.Insert(policy.Namespace) + resourceTree.ConfigMaps = append(resourceTree.ConfigMaps, configMap) + r.log.Info("processing ConfigMap", "namespace", policy.Namespace, "name", string(ro.Response.Body.ValueRef.Name)) + } + } + } +} + func (r *gatewayAPIReconciler) getNamespace(ctx context.Context, name string) (*corev1.Namespace, error) { nsKey := types.NamespacedName{Name: name} ns := new(corev1.Namespace) @@ -942,7 +975,8 @@ func (r *gatewayAPIReconciler) processClientTrafficPolicies( } // processBackendTrafficPolicies adds BackendTrafficPolicies to the resourceTree -func (r *gatewayAPIReconciler) processBackendTrafficPolicies(ctx context.Context, resourceTree *resource.Resources) error { +func (r *gatewayAPIReconciler) processBackendTrafficPolicies(ctx context.Context, resourceTree *resource.Resources, resourceMap *resourceMappings, +) error { backendTrafficPolicies := egv1a1.BackendTrafficPolicyList{} if err := r.client.List(ctx, &backendTrafficPolicies); err != nil { return fmt.Errorf("error listing BackendTrafficPolicies: %w", err) @@ -955,6 +989,7 @@ func (r *gatewayAPIReconciler) processBackendTrafficPolicies(ctx context.Context policy.Status = gwapiv1a2.PolicyStatus{} resourceTree.BackendTrafficPolicies = append(resourceTree.BackendTrafficPolicies, &policy) } + r.processBtpConfigMapRefs(ctx, resourceTree, resourceMap) return nil } @@ -1348,7 +1383,7 @@ func (r *gatewayAPIReconciler) watchResources(ctx context.Context, mgr manager.M return err } - // Watch ConfigMap CRUDs and process affected ClienTraffiPolicies and BackendTLSPolicies. + // Watch ConfigMap CRUDs and process affected EG Resources. configMapPredicates := []predicate.TypedPredicate[*corev1.ConfigMap]{ predicate.NewTypedPredicateFuncs[*corev1.ConfigMap](func(cm *corev1.ConfigMap) bool { return r.validateConfigMapForReconcile(cm) @@ -1492,6 +1527,10 @@ func (r *gatewayAPIReconciler) watchResources(ctx context.Context, mgr manager.M return err } + if err := addBtpIndexers(ctx, mgr); err != nil { + return err + } + // Watch SecurityPolicy spPredicates := []predicate.TypedPredicate[*egv1a1.SecurityPolicy]{ predicate.TypedGenerationChangedPredicate[*egv1a1.SecurityPolicy]{}, diff --git a/internal/provider/kubernetes/indexers.go b/internal/provider/kubernetes/indexers.go index 462a70542f3..2ad12069f98 100644 --- a/internal/provider/kubernetes/indexers.go +++ b/internal/provider/kubernetes/indexers.go @@ -46,6 +46,7 @@ const ( secretEnvoyProxyIndex = "secretEnvoyProxyIndex" secretEnvoyExtensionPolicyIndex = "secretEnvoyExtensionPolicyIndex" httpRouteFilterHTTPRouteIndex = "httpRouteFilterHTTPRouteIndex" + configMapBtpIndex = "configMapBtpIndex" ) func addReferenceGrantIndexers(ctx context.Context, mgr manager.Manager) error { @@ -641,6 +642,36 @@ func secretCtpIndexFunc(rawObj client.Object) []string { return secretReferences } +// addBtpIndexers adds indexing on BackendTrafficPolicy, for ConfigMap objects that are +// referenced in BackendTrafficPolicy objects. This helps in querying for BackendTrafficPolies that are +// affected by a particular ConfigMap CRUD. +func addBtpIndexers(ctx context.Context, mgr manager.Manager) error { + if err := mgr.GetFieldIndexer().IndexField(ctx, &egv1a1.BackendTrafficPolicy{}, configMapBtpIndex, configMapBtpIndexFunc); err != nil { + return err + } + + return nil +} + +func configMapBtpIndexFunc(rawObj client.Object) []string { + btp := rawObj.(*egv1a1.BackendTrafficPolicy) + var configMapReferences []string + + for _, ro := range btp.Spec.ResponseOverride { + if ro.Response.Body.ValueRef != nil { + if string(ro.Response.Body.ValueRef.Kind) == resource.KindConfigMap { + configMapReferences = append(configMapReferences, + types.NamespacedName{ + Namespace: btp.Namespace, + Name: string(ro.Response.Body.ValueRef.Name), + }.String(), + ) + } + } + } + return configMapReferences +} + // addBtlsIndexers adds indexing on BackendTLSPolicy, for ConfigMap objects that are // referenced in BackendTLSPolicy objects. This helps in querying for BackendTLSPolicies that are // affected by a particular ConfigMap CRUD. diff --git a/internal/provider/kubernetes/predicates.go b/internal/provider/kubernetes/predicates.go index 9c4d582b58b..a885d58ca62 100644 --- a/internal/provider/kubernetes/predicates.go +++ b/internal/provider/kubernetes/predicates.go @@ -588,7 +588,7 @@ func (r *gatewayAPIReconciler) handleNode(obj client.Object) bool { return true } -// validateConfigMapForReconcile checks whether the ConfigMap belongs to a valid ClientTrafficPolicy. +// validateConfigMapForReconcile checks whether the ConfigMap belongs to a valid EG resource. func (r *gatewayAPIReconciler) validateConfigMapForReconcile(obj client.Object) bool { configMap, ok := obj.(*corev1.ConfigMap) if !ok { @@ -604,8 +604,8 @@ func (r *gatewayAPIReconciler) validateConfigMapForReconcile(obj client.Object) return false } - if len(ctpList.Items) == 0 { - return false + if len(ctpList.Items) > 0 { + return true } btlsList := &gwapiv1a3.BackendTLSPolicyList{} @@ -616,11 +616,23 @@ func (r *gatewayAPIReconciler) validateConfigMapForReconcile(obj client.Object) return false } - if len(btlsList.Items) == 0 { + if len(btlsList.Items) > 0 { + return true + } + + btpList := &egv1a1.BackendTrafficPolicyList{} + if err := r.client.List(context.Background(), btpList, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(configMapBtpIndex, utils.NamespacedName(configMap).String()), + }); err != nil { + r.log.Error(err, "unable to find associated BackendTrafficPolicy") return false } - return true + if len(btpList.Items) > 0 { + return true + } + + return false } func (r *gatewayAPIReconciler) isEnvoyExtensionPolicyReferencingBackend(nsName *types.NamespacedName) bool { diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go new file mode 100644 index 00000000000..1d1bf3a5d2c --- /dev/null +++ b/internal/xds/translator/custom_response.go @@ -0,0 +1,450 @@ +// 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" + "fmt" + "strconv" + + cncfv3 "github.com/cncf/xds/go/xds/core/v3" + matcherv3 "github.com/cncf/xds/go/xds/type/matcher/v3" + typev3 "github.com/cncf/xds/go/xds/type/v3" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + respv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/custom_response/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + policyv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/custom_response/local_response_policy/v3" + envoymatcherv3 "github.com/envoyproxy/go-control-plane/envoy/type/matcher/v3" + expr "google.golang.org/genproto/googleapis/api/expr/v1alpha1" + "google.golang.org/protobuf/types/known/anypb" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +func init() { + registerHTTPFilter(&customResponse{}) +} + +type customResponse struct{} + +var _ httpFilter = &customResponse{} + +// patchHCM builds and appends the customResponse Filters to the HTTP Connection Manager +// if applicable, and it does not already exist. +// Note: this method creates an customResponse filter for each route that contains an ResponseOverride config. +// the filter is disabled by default. It is enabled on the route level. +func (c *customResponse) 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 !c.routeContainsResponseOverride(route) { + continue + } + + // Only generates one CustomResponse Envoy filter for each unique name. + // For example, if there are two routes under the same gateway with the + // same CustomResponse config, only one CustomResponse filter will be generated. + if hcmContainsFilter(mgr, c.customResponseFilterName(route.Traffic.ResponseOverride)) { + continue + } + + filter, err := c.buildHCMCustomResponseFilter(route.Traffic.ResponseOverride) + if err != nil { + errs = errors.Join(errs, err) + continue + } + + mgr.HttpFilters = append(mgr.HttpFilters, filter) + } + + return errs +} + +// buildHCMCustomResponseFilter returns an OAuth2 HTTP filter from the provided IR HTTPRoute. +func (c *customResponse) buildHCMCustomResponseFilter(ro *ir.ResponseOverride) (*hcmv3.HttpFilter, error) { + proto, err := c.customResponseConfig(ro) + if err != nil { + return nil, err + } + + if err := proto.ValidateAll(); err != nil { + return nil, err + } + + any, err := anypb.New(proto) + if err != nil { + return nil, err + } + + return &hcmv3.HttpFilter{ + Name: c.customResponseFilterName(ro), + Disabled: true, + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: any, + }, + }, nil +} + +func (c *customResponse) customResponseFilterName(ro *ir.ResponseOverride) string { + return perRouteFilterName(egv1a1.EnvoyFilterCustomResponse, ro.Name) +} + +func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3.CustomResponse, error) { + var matchers []*matcherv3.Matcher_MatcherList_FieldMatcher + + for _, r := range ro.Rules { + var ( + action *matcherv3.Matcher_OnMatch_Action + predicate *matcherv3.Matcher_MatcherList_Predicate + err error + ) + + if action, err = c.buildAction(r); err != nil { + return nil, err + } + + switch { + case len(r.Match.StatusCodes) == 0: + // This is just a sanity check, as the CRD validation should have caught this. + return nil, fmt.Errorf("missing status code in response override rule") + case len(r.Match.StatusCodes) == 1: + if predicate, err = c.buildSinglePredicate(r.Match.StatusCodes[0]); err != nil { + return nil, err + } + + matcher := &matcherv3.Matcher_MatcherList_FieldMatcher{ + Predicate: predicate, + OnMatch: &matcherv3.Matcher_OnMatch{ + OnMatch: action, + }, + } + + matchers = append(matchers, matcher) + case len(r.Match.StatusCodes) > 1: + var predicates []*matcherv3.Matcher_MatcherList_Predicate + + for _, codeMatch := range r.Match.StatusCodes { + if predicate, err = c.buildSinglePredicate(codeMatch); err != nil { + return nil, err + } + + predicates = append(predicates, predicate) + } + + // Create a single matcher that ORs all the predicates together. + // The rule will match if any of the codes match. + matcher := &matcherv3.Matcher_MatcherList_FieldMatcher{ + Predicate: &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ + OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + }, + OnMatch: &matcherv3.Matcher_OnMatch{ + OnMatch: action, + }, + } + + matchers = append(matchers, matcher) + } + + } + + // Create a MatcherList. + // The rules will be evaluated in order, and the first match wins. + cr := &respv3.CustomResponse{ + CustomResponseMatcher: &matcherv3.Matcher{ + MatcherType: &matcherv3.Matcher_MatcherList_{ + MatcherList: &matcherv3.Matcher_MatcherList{ + Matchers: matchers, + }, + }, + }, + } + + return cr, nil +} + +func (c *customResponse) buildSinglePredicate(codeMatch ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + var ( + httpAttributeCELInput *cncfv3.TypedExtensionConfig + statusCodeInput *cncfv3.TypedExtensionConfig + statusCodeCELMatcher *cncfv3.TypedExtensionConfig + err error + ) + + // Use CEL to match a range of status codes. + if codeMatch.Range != nil { + if httpAttributeCELInput, err = c.buildHTTPAttributeCELInput(); err != nil { + return nil, err + } + + if statusCodeCELMatcher, err = c.buildStatusCodeCELMatcher(*codeMatch.Range); err != nil { + return nil, err + } + + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_{ + SinglePredicate: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate{ + Input: httpAttributeCELInput, + Matcher: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_CustomMatch{ + CustomMatch: statusCodeCELMatcher, + }, + }, + }, + }, nil + } else { + // Use exact string match to match a single status code. + if statusCodeInput, err = c.buildStatusCodeInput(); err != nil { + return nil, err + } + + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_{ + SinglePredicate: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate{ + Input: statusCodeInput, + Matcher: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_ValueMatch{ + ValueMatch: &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{ + Exact: strconv.Itoa(*codeMatch.Value), + }, + }, + }, + }, + }, + }, nil + } +} + +func (c *customResponse) buildHTTPAttributeCELInput() (*cncfv3.TypedExtensionConfig, error) { + var ( + pb *anypb.Any + err error + ) + + if pb, err = anypb.New(&matcherv3.HttpAttributesCelMatchInput{}); err != nil { + return nil, err + } + + return &cncfv3.TypedExtensionConfig{ + Name: "http-attributes-cel-match-input", + TypedConfig: pb, + }, nil +} + +func (c *customResponse) buildStatusCodeInput() (*cncfv3.TypedExtensionConfig, error) { + var ( + pb *anypb.Any + err error + ) + + if pb, err = anypb.New(&envoymatcherv3.HttpResponseStatusCodeMatchInput{}); err != nil { + return nil, err + } + + return &cncfv3.TypedExtensionConfig{ + Name: "http-response-status-code-match-input", + TypedConfig: pb, + }, nil +} + +func (c *customResponse) buildStatusCodeCELMatcher(codeRange ir.StatusCodeRange) (*cncfv3.TypedExtensionConfig, error) { + var ( + pb *anypb.Any + err error + ) + + // Build the CEL expression AST: response.code >= codeRange.Start && response.code <= codeRange.End + matcher := &matcherv3.CelMatcher{ + ExprMatch: &typev3.CelExpression{ + ExprSpecifier: &typev3.CelExpression_ParsedExpr{ + ParsedExpr: &expr.ParsedExpr{ + Expr: &expr.Expr{ + Id: 9, + ExprKind: &expr.Expr_CallExpr{ + CallExpr: &expr.Expr_Call{ + Function: "_&&_", + Args: []*expr.Expr{ + { + Id: 3, + ExprKind: &expr.Expr_CallExpr{ + CallExpr: &expr.Expr_Call{ + Function: "_>=_", + Args: []*expr.Expr{ + { + Id: 2, + ExprKind: &expr.Expr_SelectExpr{ + SelectExpr: &expr.Expr_Select{ + Operand: &expr.Expr{ + Id: 1, + ExprKind: &expr.Expr_IdentExpr{ + IdentExpr: &expr.Expr_Ident{ + Name: "response", + }, + }, + }, + Field: "code", + }, + }, + }, + { + Id: 4, + ExprKind: &expr.Expr_ConstExpr{ + ConstExpr: &expr.Constant{ + ConstantKind: &expr.Constant_Int64Value{ + Int64Value: int64(codeRange.Start), + }, + }, + }, + }, + }, + }, + }, + }, + { + Id: 7, + ExprKind: &expr.Expr_CallExpr{ + CallExpr: &expr.Expr_Call{ + Function: "_<=_", + Args: []*expr.Expr{ + { + Id: 6, + ExprKind: &expr.Expr_SelectExpr{ + SelectExpr: &expr.Expr_Select{ + Operand: &expr.Expr{ + Id: 5, + ExprKind: &expr.Expr_IdentExpr{ + IdentExpr: &expr.Expr_Ident{ + Name: "response", + }, + }, + }, + Field: "code", + }, + }, + }, + { + Id: 8, + ExprKind: &expr.Expr_ConstExpr{ + ConstExpr: &expr.Constant{ + ConstantKind: &expr.Constant_Int64Value{ + Int64Value: int64(codeRange.End), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + if err := matcher.ValidateAll(); err != nil { + return nil, err + } + + if pb, err = anypb.New(matcher); err != nil { + return nil, err + } + + return &cncfv3.TypedExtensionConfig{ + Name: "cel-matcher", + TypedConfig: pb, + }, nil +} + +func (c *customResponse) buildAction(r ir.ResponseOverrideRule) (*matcherv3.Matcher_OnMatch_Action, error) { + response := &policyv3.LocalResponsePolicy{ + Body: &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineString{ + InlineString: r.Response.Body, + }, + }, + } + + if r.Response.ContentType != nil && *r.Response.ContentType != "" { + response.ResponseHeadersToAdd = append(response.ResponseHeadersToAdd, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: "Content-Type", + Value: *r.Response.ContentType, + }, + AppendAction: corev3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD, + }) + } + + var ( + pb *anypb.Any + err error + ) + + if err := response.ValidateAll(); err != nil { + return nil, err + } + + if pb, err = anypb.New(response); err != nil { + return nil, err + } + + return &matcherv3.Matcher_OnMatch_Action{ + Action: &cncfv3.TypedExtensionConfig{ + Name: r.Name, + TypedConfig: pb, + }, + }, nil +} + +// routeContainsResponseOverride returns true if ResponseOverride exists for the provided route. +func (c *customResponse) routeContainsResponseOverride(irRoute *ir.HTTPRoute) bool { + if irRoute != nil && + irRoute.Traffic != nil && + irRoute.Traffic.ResponseOverride != nil { + return true + } + return false +} + +func (c *customResponse) patchResources(tCtx *types.ResourceVersionTable, + routes []*ir.HTTPRoute, +) error { + return nil +} + +// patchRoute patches the provided route with the customResponse config if applicable. +// Note: this method enables the corresponding customResponse filter for the provided route. +func (c *customResponse) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if irRoute.Traffic == nil || irRoute.Traffic.ResponseOverride == nil { + return nil + } + filterName := c.customResponseFilterName(irRoute.Traffic.ResponseOverride) + if err := enableFilterOnRoute(route, filterName); err != nil { + return err + } + return nil +} diff --git a/internal/xds/translator/oidc.go b/internal/xds/translator/oidc.go index 963b7c8046d..e4e7b4a0216 100644 --- a/internal/xds/translator/oidc.go +++ b/internal/xds/translator/oidc.go @@ -53,9 +53,9 @@ func (*oidc) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListe continue } - // Only generates one BasicAuth Envoy filter for each unique name. + // Only generates one OAuth2 Envoy filter for each unique name. // For example, if there are two routes under the same gateway with the - // same BasicAuth config, only one BasicAuth filter will be generated. + // same OAuth2 config, only one OAuth2 filter will be generated. if hcmContainsFilter(mgr, oauth2FilterName(route.Security.OIDC)) { continue } diff --git a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml new file mode 100644 index 00000000000..cb00ac65af9 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml @@ -0,0 +1,56 @@ +http: + - address: 0.0.0.0 + hostnames: + - "*" + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: default + sectionName: http + name: default/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 + hostname: "*" + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/-1/* + traffic: + responseOverride: + name: backendtrafficpolicy/default/policy-for-gateway + rules: + - match: + statusCodes: + - value: 404 + name: backendtrafficpolicy/default/policy-for-gateway/responseoverride/rule/0 + response: + body: gateway-1 Not Found + contentType: text/plain + - match: + statusCodes: + - value: 500 + - range: + end: 511 + start: 501 + name: backendtrafficpolicy/default/policy-for-gateway/responseoverride/rule/1 + response: + body: | + { + "error": "Internal Server Error" + } + contentType: application/json diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response.clusters.yaml new file mode 100644 index 00000000000..9714612e3de --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.clusters.yaml @@ -0,0 +1,17 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-1/rule/0 + lbPolicy: LEAST_REQUEST + name: httproute/default/httproute-1/rule/0 + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response.endpoints.yaml new file mode 100644 index 00000000000..29bb6b4e444 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.endpoints.yaml @@ -0,0 +1,12 @@ +- 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 diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml new file mode 100644 index 00000000000..19c56586960 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml @@ -0,0 +1,130 @@ +- 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.custom_response/backendtrafficpolicy/default/policy-for-gateway + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.custom_response.v3.CustomResponse + customResponseMatcher: + matcherList: + matchers: + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-gateway/responseoverride/rule/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + body: + inlineString: gateway-1 Not Found + responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: text/plain + predicate: + singlePredicate: + input: + name: http-response-status-code-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput + valueMatch: + exact: "404" + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-gateway/responseoverride/rule/1 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + body: + inlineString: | + { + "error": "Internal Server Error" + } + responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: application/json + predicate: + orMatcher: + predicate: + - singlePredicate: + input: + name: http-response-status-code-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput + valueMatch: + exact: "500" + - singlePredicate: + customMatch: + name: cel-matcher + typedConfig: + '@type': type.googleapis.com/xds.type.matcher.v3.CelMatcher + exprMatch: + parsedExpr: + expr: + callExpr: + args: + - callExpr: + args: + - id: "2" + selectExpr: + field: code + operand: + id: "1" + identExpr: + name: response + - constExpr: + int64Value: "501" + id: "4" + function: _>=_ + id: "3" + - callExpr: + args: + - id: "6" + selectExpr: + field: code + operand: + id: "5" + identExpr: + name: response + - constExpr: + int64Value: "511" + id: "8" + function: _<=_ + id: "7" + function: _&&_ + id: "9" + input: + name: http-attributes-cel-match-input + typedConfig: + '@type': type.googleapis.com/xds.type.matcher.v3.HttpAttributesCelMatchInput + - 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: default/gateway-1/http + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: default/gateway-1/http + name: default/gateway-1/http + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response.routes.yaml new file mode 100644 index 00000000000..8262bb6f325 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.routes.yaml @@ -0,0 +1,33 @@ +- ignorePortInHostMatching: true + name: default/gateway-1/http + virtualHosts: + - domains: + - '*' + metadata: + filterMetadata: + envoy-gateway: + resources: + - kind: Gateway + name: gateway-1 + namespace: default + sectionName: http + name: default/gateway-1/http/* + routes: + - match: + prefix: / + metadata: + filterMetadata: + envoy-gateway: + resources: + - kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/-1/* + route: + cluster: httproute/default/httproute-1/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.custom_response/backendtrafficpolicy/default/policy-for-gateway: + '@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 fe361099a84..4f562fce611 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -483,6 +483,7 @@ _Appears in:_ | `rateLimit` | _[RateLimitSpec](#ratelimitspec)_ | false | RateLimit allows the user to limit the number of incoming requests
to a predefined value based on attributes within the traffic flow. | | `faultInjection` | _[FaultInjection](#faultinjection)_ | false | FaultInjection defines the fault injection policy to be applied. This configuration can be used to
inject delays and abort requests to mimic failure scenarios such as service failures and overloads | | `useClientProtocol` | _boolean_ | false | UseClientProtocol configures Envoy to prefer sending requests to backends using
the same HTTP protocol that the incoming request used. Defaults to false, which means
that Envoy will use the protocol indicated by the attached BackendRef. | +| `responseOverride` | _[ResponseOverride](#responseoverride) array_ | false | ResponseOverride defines the configuration to override specific responses with a custom one.
If multiple configurations are specified, the first one to match wins. | #### BasicAuth @@ -866,7 +867,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | | `contentType` | _string_ | false | Content Type of the response. This will be set in the Content-Type header. | -| `body` | _[CustomResponseBody](#customresponsebody)_ | false | Body of the Custom Response | +| `body` | _[CustomResponseBody](#customresponsebody)_ | true | Body of the Custom Response | #### CustomResponseBody @@ -881,9 +882,9 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `type` | _[ResponseValueType](#responsevaluetype)_ | true | Type is the type of method to use to read the body value. | +| `type` | _[ResponseValueType](#responsevaluetype)_ | true | Type is the type of method to use to read the body value.
Valid values are Inline and ValueRef, default is Inline. | | `inline` | _string_ | false | Inline contains the value as an inline string. | -| `valueRef` | _[LocalObjectReference](#localobjectreference)_ | false | ValueRef contains the contents of the body
specified as a local object reference.
Only a reference to ConfigMap is supported. | +| `valueRef` | _[LocalObjectReference](#localobjectreference)_ | false | ValueRef contains the contents of the body
specified as a local object reference.
Only a reference to ConfigMap is supported.

The value of key `response.body` in the ConfigMap will be used as the response body.
If the key is not found, the first value in the ConfigMap will be used. | #### CustomResponseMatch @@ -897,7 +898,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `statusCode` | _[StatusCodeMatch](#statuscodematch) array_ | true | Status code to match on. The match evaluates to true if any of the matches are successful. | +| `statusCodes` | _[StatusCodeMatch](#statuscodematch) array_ | true | Status code to match on. The match evaluates to true if any of the matches are successful. | #### CustomTag @@ -1026,6 +1027,7 @@ _Appears in:_ | `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.
| +| `envoy.filters.http.custom_response` | EnvoyFilterCustomResponse defines the Envoy HTTP custom response filter.
| | `envoy.filters.http.router` | EnvoyFilterRouter defines the Envoy HTTP router filter.
| @@ -1479,7 +1481,7 @@ _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.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.stateful_session

- 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.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.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.stateful_session

- 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.custom_response

- 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. | @@ -3573,6 +3575,10 @@ ResponseValueType defines the types of values for the response body supported by _Appears in:_ - [CustomResponseBody](#customresponsebody) +| Value | Description | +| ----- | ----------- | +| `Inline` | ResponseValueTypeInline defines the "Inline" response body type.
| +| `ValueRef` | ResponseValueTypeValueRef defines the "ValueRef" response body type.
| @@ -3821,16 +3827,16 @@ _Appears in:_ - +StatusCodeMatch defines the configuration for matching a status code. _Appears in:_ - [CustomResponseMatch](#customresponsematch) | Field | Type | Required | Description | | --- | --- | --- | --- | -| `type` | _[StatusCodeValueType](#statuscodevaluetype)_ | true | Type is the type of value. | -| `value` | _string_ | false | Value contains the value of the status code. | -| `range` | _[StatusCodeRange](#statuscoderange)_ | false | ValueRef contains the contents of the body
specified as a local object reference.
Only a reference to ConfigMap is supported. | +| `type` | _[StatusCodeValueType](#statuscodevaluetype)_ | true | Type is the type of value.
Valid values are Value and Range, default is Value. | +| `value` | _integer_ | false | Value contains the value of the status code. | +| `range` | _[StatusCodeRange](#statuscoderange)_ | false | Range contains the range of status codes. | #### StatusCodeRange @@ -3857,6 +3863,10 @@ StatusCodeValueType defines the types of values for the status code match suppor _Appears in:_ - [StatusCodeMatch](#statuscodematch) +| Value | Description | +| ----- | ----------- | +| `Value` | StatusCodeValueTypeValue defines the "Value" status code match type.
| +| `Range` | StatusCodeValueTypeRange defines the "Range" status code match type.
| #### StringMatch diff --git a/site/content/zh/latest/api/extension_types.md b/site/content/zh/latest/api/extension_types.md index fe361099a84..4f562fce611 100644 --- a/site/content/zh/latest/api/extension_types.md +++ b/site/content/zh/latest/api/extension_types.md @@ -483,6 +483,7 @@ _Appears in:_ | `rateLimit` | _[RateLimitSpec](#ratelimitspec)_ | false | RateLimit allows the user to limit the number of incoming requests
to a predefined value based on attributes within the traffic flow. | | `faultInjection` | _[FaultInjection](#faultinjection)_ | false | FaultInjection defines the fault injection policy to be applied. This configuration can be used to
inject delays and abort requests to mimic failure scenarios such as service failures and overloads | | `useClientProtocol` | _boolean_ | false | UseClientProtocol configures Envoy to prefer sending requests to backends using
the same HTTP protocol that the incoming request used. Defaults to false, which means
that Envoy will use the protocol indicated by the attached BackendRef. | +| `responseOverride` | _[ResponseOverride](#responseoverride) array_ | false | ResponseOverride defines the configuration to override specific responses with a custom one.
If multiple configurations are specified, the first one to match wins. | #### BasicAuth @@ -866,7 +867,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | | `contentType` | _string_ | false | Content Type of the response. This will be set in the Content-Type header. | -| `body` | _[CustomResponseBody](#customresponsebody)_ | false | Body of the Custom Response | +| `body` | _[CustomResponseBody](#customresponsebody)_ | true | Body of the Custom Response | #### CustomResponseBody @@ -881,9 +882,9 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `type` | _[ResponseValueType](#responsevaluetype)_ | true | Type is the type of method to use to read the body value. | +| `type` | _[ResponseValueType](#responsevaluetype)_ | true | Type is the type of method to use to read the body value.
Valid values are Inline and ValueRef, default is Inline. | | `inline` | _string_ | false | Inline contains the value as an inline string. | -| `valueRef` | _[LocalObjectReference](#localobjectreference)_ | false | ValueRef contains the contents of the body
specified as a local object reference.
Only a reference to ConfigMap is supported. | +| `valueRef` | _[LocalObjectReference](#localobjectreference)_ | false | ValueRef contains the contents of the body
specified as a local object reference.
Only a reference to ConfigMap is supported.

The value of key `response.body` in the ConfigMap will be used as the response body.
If the key is not found, the first value in the ConfigMap will be used. | #### CustomResponseMatch @@ -897,7 +898,7 @@ _Appears in:_ | Field | Type | Required | Description | | --- | --- | --- | --- | -| `statusCode` | _[StatusCodeMatch](#statuscodematch) array_ | true | Status code to match on. The match evaluates to true if any of the matches are successful. | +| `statusCodes` | _[StatusCodeMatch](#statuscodematch) array_ | true | Status code to match on. The match evaluates to true if any of the matches are successful. | #### CustomTag @@ -1026,6 +1027,7 @@ _Appears in:_ | `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.
| +| `envoy.filters.http.custom_response` | EnvoyFilterCustomResponse defines the Envoy HTTP custom response filter.
| | `envoy.filters.http.router` | EnvoyFilterRouter defines the Envoy HTTP router filter.
| @@ -1479,7 +1481,7 @@ _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.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.stateful_session

- 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.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.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.stateful_session

- 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.custom_response

- 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. | @@ -3573,6 +3575,10 @@ ResponseValueType defines the types of values for the response body supported by _Appears in:_ - [CustomResponseBody](#customresponsebody) +| Value | Description | +| ----- | ----------- | +| `Inline` | ResponseValueTypeInline defines the "Inline" response body type.
| +| `ValueRef` | ResponseValueTypeValueRef defines the "ValueRef" response body type.
| @@ -3821,16 +3827,16 @@ _Appears in:_ - +StatusCodeMatch defines the configuration for matching a status code. _Appears in:_ - [CustomResponseMatch](#customresponsematch) | Field | Type | Required | Description | | --- | --- | --- | --- | -| `type` | _[StatusCodeValueType](#statuscodevaluetype)_ | true | Type is the type of value. | -| `value` | _string_ | false | Value contains the value of the status code. | -| `range` | _[StatusCodeRange](#statuscoderange)_ | false | ValueRef contains the contents of the body
specified as a local object reference.
Only a reference to ConfigMap is supported. | +| `type` | _[StatusCodeValueType](#statuscodevaluetype)_ | true | Type is the type of value.
Valid values are Value and Range, default is Value. | +| `value` | _integer_ | false | Value contains the value of the status code. | +| `range` | _[StatusCodeRange](#statuscoderange)_ | false | Range contains the range of status codes. | #### StatusCodeRange @@ -3857,6 +3863,10 @@ StatusCodeValueType defines the types of values for the status code match suppor _Appears in:_ - [StatusCodeMatch](#statuscodematch) +| Value | Description | +| ----- | ----------- | +| `Value` | StatusCodeValueTypeValue defines the "Value" status code match type.
| +| `Range` | StatusCodeValueTypeRange defines the "Range" status code match type.
| #### StringMatch diff --git a/test/cel-validation/backendtrafficpolicy_test.go b/test/cel-validation/backendtrafficpolicy_test.go index 49f033eb6ae..d5e6a1b2d1f 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -1257,6 +1257,251 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { }, wantErrors: []string{}, }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Type: ptr.To(egv1a1.StatusCodeValueTypeValue), + Range: &egv1a1.StatusCodeRange{ + Start: 100, + End: 200, + }, + }, + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "value must be set for type Value", + }, + }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Range: &egv1a1.StatusCodeRange{ + Start: 100, + End: 200, + }, + }, + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "value must be set for type Value", + }, + }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Type: ptr.To(egv1a1.StatusCodeValueTypeRange), + Value: ptr.To(100), + }, + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "range must be set for type Range", + }, + }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Type: ptr.To(egv1a1.StatusCodeValueTypeRange), + Range: &egv1a1.StatusCodeRange{ + Start: 200, + End: 100, + }, + }, + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "end must be greater than start", + }, + }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Value: ptr.To(100), + }, + }, + }, + Response: egv1a1.CustomResponse{ + Body: egv1a1.CustomResponseBody{ + ValueRef: &gwapiv1a2.LocalObjectReference{ + Kind: gwapiv1a2.Kind("ConfigMap"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "inline must be set for type Inline", + }, + }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Value: ptr.To(100), + }, + }, + }, + Response: egv1a1.CustomResponse{ + Body: egv1a1.CustomResponseBody{ + Type: ptr.To(egv1a1.ResponseValueTypeValueRef), + Inline: ptr.To("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "valueRef must be set for type ValueRef", + }, + }, + { + desc: "both targetref and targetrefs specified", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1a2.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1a2.LocalPolicyTargetReference{ + Group: gwapiv1a2.Group("gateway.networking.k8s.io"), + Kind: gwapiv1a2.Kind("Gateway"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Value: ptr.To(100), + }, + }, + }, + Response: egv1a1.CustomResponse{ + Body: egv1a1.CustomResponseBody{ + Type: ptr.To(egv1a1.ResponseValueTypeValueRef), + ValueRef: &gwapiv1a2.LocalObjectReference{ + Kind: gwapiv1a2.Kind("Foo"), + Name: gwapiv1a2.ObjectName("eg"), + }, + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "only ConfigMap is supported for ValueRe", + }, + }, } for _, tc := range cases { diff --git a/test/e2e/testdata/response-override.yaml b/test/e2e/testdata/response-override.yaml new file mode 100644 index 00000000000..084747aaa6c --- /dev/null +++ b/test/e2e/testdata/response-override.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: response-override + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: response-override-config + namespace: gateway-conformance-infra +data: + response.body: '{"error": "Internal Server Error"}' +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: response-override + namespace: gateway-conformance-infra +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: response-override + responseOverride: + - match: + statusCodes: + - type: Value + value: 404 + response: + contentType: text/plain + body: + type: Inline + inline: "Oops! Your request is not found." + - match: + statusCodes: + - type: Value + value: 500 + - type: Range + range: + start: 501 + end: 511 + response: + contentType: application/json + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: response-override-config diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go new file mode 100644 index 00000000000..b21db88e242 --- /dev/null +++ b/test/e2e/tests/response-override.go @@ -0,0 +1,83 @@ +// 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 ( + "fmt" + "io" + "net/http" + "net/url" + "testing" + + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + httputils "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, ResponseOverrideTest) +} + +var ResponseOverrideTest = suite.ConformanceTest{ + ShortName: "ResponseOverrideSpecificUser", + Description: "Response Override", + Manifests: []string{"testdata/response-override.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("response override", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "response-override", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + ancestorRef := gwapiv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + } + BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "response-override", Namespace: ns}, suite.ControllerName, ancestorRef) + verifyResponseOverride(t, gwAddr, 404, "text/plain", "Oops! Your request is not found.") + verifyResponseOverride(t, gwAddr, 500, "application/json", `{"error": "Internal Server Error"}`) + }) + }, +} + +func verifyResponseOverride(t *testing.T, gwAddr string, statusCode int, expectedContentType string, expectedBody string) { + reqURL := url.URL{ + Scheme: "http", + Host: httputils.CalculateHost(t, gwAddr, "http"), + Path: fmt.Sprintf("/status/%d", statusCode), + } + + rsp, err := http.Get(reqURL.String()) + if err != nil { + t.Fatalf("failed to get response: %v", err) + } + + // Verify that the response body is overridden + defer rsp.Body.Close() + body, err := io.ReadAll(rsp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + if string(body) != expectedBody { + t.Errorf("expected response body to be %s but got %s", expectedBody, string(body)) + } + + // Verify that the content type is overridden + contentType := rsp.Header.Get("Content-Type") + if contentType != expectedContentType { + t.Errorf("expected content type to be %s but got %s", expectedContentType, contentType) + } +}