From 0591fad6903c58ec4c7de9142c6da00e86b13eec Mon Sep 17 00:00:00 2001 From: kabbohus Date: Wed, 24 Jun 2026 19:13:18 +0200 Subject: [PATCH 1/9] feat: extend response override in BackendTrafficPolicy to match response headers in addition to response status codes Signed-off-by: kabbohus --- api/v1alpha1/shared_types.go | 27 ++- api/v1alpha1/zz_generated.deepcopy.go | 23 +++ ....envoyproxy.io_backendtrafficpolicies.yaml | 51 ++++- ....envoyproxy.io_backendtrafficpolicies.yaml | 51 ++++- internal/gatewayapi/backendtrafficpolicy.go | 7 + ...afficpolicy-with-response-override.in.yaml | 26 +++ ...fficpolicy-with-response-override.out.yaml | 58 ++++++ internal/ir/xds.go | 16 +- internal/ir/zz_generated.deepcopy.go | 23 +++ internal/xds/translator/custom_response.go | 182 ++++++++++++++---- .../testdata/in/xds-ir/custom-response.yaml | 26 +++ .../out/xds-ir/custom-response.listeners.yaml | 65 +++++++ ...onse-override-with-backendtrafficpolicy.md | 1 + site/content/en/latest/api/extension_types.md | 20 +- .../backendtrafficpolicy_test.go | 108 +++++++++++ test/e2e/testdata/response-override.yaml | 15 ++ test/e2e/tests/direct-response.go | 38 ++-- test/e2e/tests/response-override.go | 138 +++++++++---- test/helm/gateway-crds-helm/all.out.yaml | 51 ++++- test/helm/gateway-crds-helm/e2e.out.yaml | 51 ++++- .../envoy-gateway-crds.out.yaml | 51 ++++- 21 files changed, 927 insertions(+), 101 deletions(-) create mode 100644 release-notes/current/new_features/0000-support-response-header-in-response-override-with-backendtrafficpolicy.md diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index d33f6544d3..c22c380a6f 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -819,11 +819,36 @@ type ResponseOverride struct { } // CustomResponseMatch defines the configuration for matching a user response to return a custom one. +// When both statusCodes and responseHeaders are specified, both must match. +// +kubebuilder:validation:XValidation:rule="has(self.statusCodes) || has(self.responseHeaders)",message="at least one of statusCodes or responseHeaders must be specified" type CustomResponseMatch struct { // Status code to match on. The match evaluates to true if any of the matches are successful. + // + // +optional // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=50 - StatusCodes []StatusCodeMatch `json:"statusCodes"` + StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` + + // Response headers to match on. The match evaluates to true if any of the matches are successful. + // + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + ResponseHeaders []ResponseOverrideHeaderMatch `json:"responseHeaders,omitempty"` +} + +// ResponseOverrideHeaderMatch defines the configuration for matching a response header. +type ResponseOverrideHeaderMatch struct { + // Name of the HTTP header. + // The header name is case-insensitive. + // For example, "Foo" and "foo" are considered the same header. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Name string `json:"name"` + + // Value within the HTTP header to match against. + Value StringMatch `json:"value"` } // StatusCodeValueType defines the types of values for the status code match supported by Envoy Gateway. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9c32ace0fa..9bc1132231 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2021,6 +2021,13 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ResponseHeaders != nil { + in, out := &in.ResponseHeaders, &out.ResponseHeaders + *out = make([]ResponseOverrideHeaderMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponseMatch. @@ -7760,6 +7767,22 @@ func (in *ResponseOverride) DeepCopy() *ResponseOverride { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverrideHeaderMatch) DeepCopyInto(out *ResponseOverrideHeaderMatch) { + *out = *in + in.Value.DeepCopyInto(&out.Value) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideHeaderMatch. +func (in *ResponseOverrideHeaderMatch) DeepCopy() *ResponseOverrideHeaderMatch { + if in == nil { + return nil + } + out := new(ResponseOverrideHeaderMatch) + 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 diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index f18e9f8334..5fa26e2b1d 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2344,6 +2344,51 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if any of the matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2399,9 +2444,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 395dfccd57..5df9c443bb 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2343,6 +2343,51 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if any of the matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2398,9 +2443,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index 933ba491fb..08175ead45 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -1938,6 +1938,13 @@ func (t *Translator) buildResponseOverride(policy *egv1a1.BackendTrafficPolicy, } } + for _, h := range ro.Match.ResponseHeaders { + match.ResponseHeaders = append(match.ResponseHeaders, ir.ResponseOverrideHeaderMatch{ + Name: h.Name, + Value: *irStringMatch(h.Name, h.Value), + }) + } + if ro.Redirect != nil { redirect := &ir.Redirect{ Scheme: ro.Redirect.Scheme, diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml index 0e0246d2ef..fc8fb07777 100644 --- a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.in.yaml @@ -204,6 +204,32 @@ backendTrafficPolicies: - value: 403 response: statusCode: 401 + - match: + responseHeaders: + - name: X-Custom-Header + value: + type: Exact + value: custom-value + response: + contentType: text/plain + body: + inline: "matched response header" + - match: + statusCodes: + - value: 503 + responseHeaders: + - name: X-Error-Type + value: + type: Prefix + value: "upstream-" + - name: X-Region + value: + type: RegularExpression + value: "^us-.*$" + response: + contentType: application/json + body: + inline: '{"error":"combined match"}' - apiVersion: gateway.envoyproxy.io/v1alpha1 kind: BackendTrafficPolicy metadata: diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml index 429804c25b..a81c6c6643 100644 --- a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml @@ -68,6 +68,35 @@ backendTrafficPolicies: value: 403 response: statusCode: 401 + - match: + responseHeaders: + - name: X-Custom-Header + value: + type: Exact + value: custom-value + response: + body: + inline: matched response header + type: null + contentType: text/plain + - match: + responseHeaders: + - name: X-Error-Type + value: + type: Prefix + value: upstream- + - name: X-Region + value: + type: RegularExpression + value: ^us-.*$ + statusCodes: + - type: null + value: 503 + response: + body: + inline: '{"error":"combined match"}' + type: null + contentType: application/json targetRef: group: gateway.networking.k8s.io kind: HTTPRoute @@ -710,6 +739,35 @@ xdsIR: name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/0 response: statusCode: 401 + - match: + responseHeaders: + - name: X-Custom-Header + value: + distinct: false + exact: custom-value + name: X-Custom-Header + name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/1 + response: + body: bWF0Y2hlZCByZXNwb25zZSBoZWFkZXI= + contentType: text/plain + - match: + responseHeaders: + - name: X-Error-Type + value: + distinct: false + name: X-Error-Type + prefix: upstream- + - name: X-Region + value: + distinct: false + name: X-Region + safeRegex: ^us-.*$ + statusCodes: + - value: 503 + name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/2 + response: + body: eyJlcnJvciI6ImNvbWJpbmVkIG1hdGNoIn0= + contentType: application/json - destination: metadata: kind: HTTPRoute diff --git a/internal/ir/xds.go b/internal/ir/xds.go index db6d2f0935..02403bd298 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -750,10 +750,24 @@ type ResponseOverrideRule struct { } // CustomResponseMatch defines the configuration for matching a user response to return a custom one. +// When both statusCodes and responseHeaders are specified, both must match. // +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"` + StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` + + // Response headers to match on. The match evaluates to true if any of the matches are successful. + ResponseHeaders []ResponseOverrideHeaderMatch `json:"responseHeaders,omitempty"` +} + +// ResponseOverrideHeaderMatch defines the configuration for matching a response header. +// +k8s:deepcopy-gen=true +type ResponseOverrideHeaderMatch struct { + // Name of the HTTP header. The header name is case-insensitive. + Name string `json:"name"` + + // Value within the HTTP header to match against. + Value StringMatch `json:"value"` } // StatusCodeMatch defines the configuration for matching a status code. diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index cfbfc5a62d..b41dc6711d 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1194,6 +1194,13 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.ResponseHeaders != nil { + in, out := &in.ResponseHeaders, &out.ResponseHeaders + *out = make([]ResponseOverrideHeaderMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomResponseMatch. @@ -4165,6 +4172,22 @@ func (in *ResponseOverride) DeepCopy() *ResponseOverride { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverrideHeaderMatch) DeepCopyInto(out *ResponseOverrideHeaderMatch) { + *out = *in + in.Value.DeepCopyInto(&out.Value) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideHeaderMatch. +func (in *ResponseOverrideHeaderMatch) DeepCopy() *ResponseOverrideHeaderMatch { + if in == nil { + return nil + } + out := new(ResponseOverrideHeaderMatch) + 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 diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go index 65977b6791..e85ae0ae18 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -77,7 +77,7 @@ func (c *customResponse) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener * return errs } -// buildHCMCustomResponseFilter returns an OAuth2 HTTP filter from the provided IR HTTPRoute. +// buildHCMCustomResponseFilter returns an Custom Response HTTP filter from the provided IR ResponseOverride. func (c *customResponse) buildHCMCustomResponseFilter(ro *ir.ResponseOverride) (*hcmv3.HttpFilter, error) { config, err := c.customResponseConfig(ro) if err != nil { @@ -106,62 +106,48 @@ func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3. for _, r := range ro.Rules { var ( - action *matcherv3.Matcher_OnMatch_Action - predicate *matcherv3.Matcher_MatcherList_Predicate - err error + action *matcherv3.Matcher_OnMatch_Action + predicates []*matcherv3.Matcher_MatcherList_Predicate + err error ) - if action, err = c.buildAction(r); err != nil { + 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 { + if len(r.Match.StatusCodes) > 0 { + statusCodePredicate, err := c.buildStatusCodePredicate(r.Match.StatusCodes) + if err != nil { return nil, err } - 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, statusCodePredicate) + } - predicates = append(predicates, predicate) + if len(r.Match.ResponseHeaders) > 0 { + headerPredicate, err := c.buildResponseHeaderPredicate(r.Match.ResponseHeaders) + if err != nil { + return nil, err } + predicates = append(predicates, headerPredicate) + } - // Create a single predicate that ORs all the status code predicates together. - predicate = &matcherv3.Matcher_MatcherList_Predicate{ - MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ - OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ - Predicate: predicates, - }, - }, - } + if len(predicates) == 0 { + // This is just a sanity check, as the CRD validation should have caught this. + return nil, fmt.Errorf("missing match criteria in response override rule") } - // For Local or Backend sources, AND the status code predicate with a - // local_reply predicate so the rule only fires for the correct response origin. + // For Local or Backend sources, AND a local_reply predicate so the rule + // only fires for the correct response origin. if r.Source == egv1a1.ResponseOverrideSourceLocal || r.Source == egv1a1.ResponseOverrideSourceBackend { localReplyPredicate, err := c.buildLocalReplyPredicate(r.Source == egv1a1.ResponseOverrideSourceLocal) if err != nil { return nil, err } - predicate = &matcherv3.Matcher_MatcherList_Predicate{ - MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ - AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ - Predicate: []*matcherv3.Matcher_MatcherList_Predicate{predicate, localReplyPredicate}, - }, - }, - } + predicates = append(predicates, localReplyPredicate) } matchers = append(matchers, &matcherv3.Matcher_MatcherList_FieldMatcher{ - Predicate: predicate, + Predicate: andPredicate(predicates), OnMatch: &matcherv3.Matcher_OnMatch{ OnMatch: action, }, @@ -183,7 +169,105 @@ func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3. return cr, nil } -func (c *customResponse) buildSinglePredicate(codeMatch ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { +func andPredicate(predicates []*matcherv3.Matcher_MatcherList_Predicate) *matcherv3.Matcher_MatcherList_Predicate { + if len(predicates) == 1 { + return predicates[0] + } + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ + AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + } +} + +func (c *customResponse) buildStatusCodePredicate(codes []ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + predicates := make([]*matcherv3.Matcher_MatcherList_Predicate, 0, len(codes)) + for _, codeMatch := range codes { + predicate, err := c.buildSingleStatusCodePredicate(codeMatch) + if err != nil { + return nil, err + } + predicates = append(predicates, predicate) + } + + if len(predicates) == 1 { + return predicates[0], nil + } + + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ + OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + }, nil +} + +func (c *customResponse) buildResponseHeaderPredicate(headers []ir.ResponseOverrideHeaderMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + predicates := make([]*matcherv3.Matcher_MatcherList_Predicate, 0, len(headers)) + for _, header := range headers { + input, err := c.buildResponseHeaderInput(header.Name) + if err != nil { + return nil, err + } + valueMatcher, err := buildStringMatcher(header.Value) + if err != nil { + return nil, err + } + predicates = append(predicates, &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_{ + SinglePredicate: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate{ + Input: input, + Matcher: &matcherv3.Matcher_MatcherList_Predicate_SinglePredicate_ValueMatch{ + ValueMatch: valueMatcher, + }, + }, + }, + }, nil) + } + + if len(predicates) == 1 { + return predicates[0], nil + } + + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ + AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + }, nil +} + +func buildStringMatcher(irMatch ir.StringMatch) (*matcherv3.StringMatcher, error) { + switch { + case irMatch.Exact != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Exact{Exact: *irMatch.Exact}, + }, nil + case irMatch.Prefix != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Prefix{Prefix: *irMatch.Prefix}, + }, nil + case irMatch.Suffix != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_Suffix{Suffix: *irMatch.Suffix}, + }, nil + case irMatch.SafeRegex != nil: + return &matcherv3.StringMatcher{ + MatchPattern: &matcherv3.StringMatcher_SafeRegex{SafeRegex: &matcherv3.RegexMatcher{ + Regex: *irMatch.SafeRegex, + EngineType: &matcherv3.RegexMatcher_GoogleRe2{GoogleRe2: &matcherv3.RegexMatcher_GoogleRE2{}}, + }}, + }, nil + default: + return nil, fmt.Errorf("unsupported string matcher") + } +} + +func (c *customResponse) buildSingleStatusCodePredicate(codeMatch ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { var ( httpAttributeCELInput *cncfv3.TypedExtensionConfig statusCodeInput *cncfv3.TypedExtensionConfig @@ -266,6 +350,22 @@ func (c *customResponse) buildStatusCodeInput() (*cncfv3.TypedExtensionConfig, e }, nil } +func (c *customResponse) buildResponseHeaderInput(headerName string) (*cncfv3.TypedExtensionConfig, error) { + var ( + pb *anypb.Any + err error + ) + + if pb, err = proto.ToAnyWithValidation(&envoymatcherv3.HttpResponseHeaderMatchInput{HeaderName: headerName}); err != nil { + return nil, err + } + + return &cncfv3.TypedExtensionConfig{ + Name: "http-response-header-match-input", + TypedConfig: pb, + }, nil +} + func (c *customResponse) buildLocalReplyInput() (*cncfv3.TypedExtensionConfig, error) { pb, err := proto.ToAnyWithValidation(&envoymatcherv3.HttpResponseLocalReplyMatchInput{}) if err != nil { @@ -407,7 +507,7 @@ func (c *customResponse) buildStatusCodeCELMatcher(codeRange ir.StatusCodeRange) }, nil } -func (c *customResponse) buildAction(r ir.ResponseOverrideRule) (*matcherv3.Matcher_OnMatch_Action, error) { +func (c *customResponse) buildAction(r *ir.ResponseOverrideRule) (*matcherv3.Matcher_OnMatch_Action, error) { var ( pb *anypb.Any err error @@ -430,7 +530,7 @@ func (c *customResponse) buildAction(r ir.ResponseOverrideRule) (*matcherv3.Matc }, nil } -func (c *customResponse) buildRedirectAction(r ir.ResponseOverrideRule) (*anypb.Any, error) { +func (c *customResponse) buildRedirectAction(r *ir.ResponseOverrideRule) (*anypb.Any, error) { redirectAction := &routev3.RedirectAction{} if r.Redirect.Scheme != nil { redirectAction.SchemeRewriteSpecifier = &routev3.RedirectAction_SchemeRedirect{ @@ -458,7 +558,7 @@ func (c *customResponse) buildRedirectAction(r ir.ResponseOverrideRule) (*anypb. return proto.ToAnyWithValidation(redirect) } -func (c *customResponse) buildResponseAction(r ir.ResponseOverrideRule) (*anypb.Any, error) { +func (c *customResponse) buildResponseAction(r *ir.ResponseOverrideRule) (*anypb.Any, error) { response := &policyv3.LocalResponsePolicy{} if len(r.Response.Body) > 0 { diff --git a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml index 61e41caf85..3ae9894428 100644 --- a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml +++ b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml @@ -98,3 +98,29 @@ http: name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/0 response: statusCode: 404 + - match: + responseHeaders: + - name: X-Custom-Header + value: + name: X-Custom-Header + exact: custom-value + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/1 + response: + body: bWF0Y2hlZCByZXNwb25zZSBoZWFkZXI= + contentType: text/plain + - match: + statusCodes: + - value: 503 + responseHeaders: + - name: X-Error-Type + value: + name: X-Error-Type + prefix: upstream- + - name: X-Region + value: + name: X-Region + safeRegex: "^us-.*$" + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/2 + response: + body: eyJlcnJvciI6ImNvbWJpbmVkIG1hdGNoIn0= + contentType: application/json 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 index 7d1377deb5..36e7c1890e 100644 --- a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml @@ -157,6 +157,71 @@ '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput valueMatch: exact: "403" + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/1 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + bodyFormat: + textFormat: matched response header + responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: text/plain + predicate: + andMatcher: + predicate: + - singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Custom-Header + valueMatch: + exact: custom-value + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/2 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + bodyFormat: + textFormat: '{"error":"combined match"}' + responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: application/json + predicate: + andMatcher: + predicate: + - singlePredicate: + input: + name: http-response-status-code-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput + valueMatch: + exact: "503" + - andMatcher: + predicate: + - singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Error-Type + valueMatch: + prefix: upstream- + - singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Region + valueMatch: + safeRegex: + googleRe2: {} + regex: ^us-.*$ - name: envoy.filters.http.router typedConfig: '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router diff --git a/release-notes/current/new_features/0000-support-response-header-in-response-override-with-backendtrafficpolicy.md b/release-notes/current/new_features/0000-support-response-header-in-response-override-with-backendtrafficpolicy.md new file mode 100644 index 0000000000..16f6b8b302 --- /dev/null +++ b/release-notes/current/new_features/0000-support-response-header-in-response-override-with-backendtrafficpolicy.md @@ -0,0 +1 @@ +Added support for matching on response headers in `BackendTrafficPolicy` `responseOverride` via the `match.responseHeaders` field. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index ad331bbbdc..a967ef2ebd 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1365,13 +1365,15 @@ _Appears in:_ CustomResponseMatch defines the configuration for matching a user response to return a custom one. +When both statusCodes and responseHeaders are specified, both must match. _Appears in:_ - [ResponseOverride](#responseoverride) | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | -| `statusCodes` | _[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_ | false | | Status code to match on. The match evaluates to true if any of the matches are successful. | +| `responseHeaders` | _[ResponseOverrideHeaderMatch](#responseoverrideheadermatch) array_ | false | | Response headers to match on. The match evaluates to true if any of the matches are successful. | #### CustomTag @@ -5629,6 +5631,21 @@ _Appears in:_ | `source` | _[ResponseOverrideSource](#responseoverridesource)_ | false | | Source specifies which responses this rule applies to.
Local overrides only Envoy-generated responses (e.g. auth failures).
Backend overrides only upstream responses.
All (default) overrides both. | +#### ResponseOverrideHeaderMatch + + + +ResponseOverrideHeaderMatch defines the configuration for matching a response header. + +_Appears in:_ +- [CustomResponseMatch](#customresponsematch) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | _string_ | true | | Name of the HTTP header.
The header name is case-insensitive.
For example, "Foo" and "foo" are considered the same header. | +| `value` | _[StringMatch](#stringmatch)_ | true | | Value within the HTTP header to match against. | + + #### ResponseOverrideSource _Underlying type:_ _string_ @@ -6092,6 +6109,7 @@ _Appears in:_ - [OIDCDenyRedirectHeader](#oidcdenyredirectheader) - [OtherSANMatch](#othersanmatch) - [ProxyMetrics](#proxymetrics) +- [ResponseOverrideHeaderMatch](#responseoverrideheadermatch) - [SubjectAltNames](#subjectaltnames) | Field | Type | Required | Default | Description | diff --git a/test/cel-validation/backendtrafficpolicy_test.go b/test/cel-validation/backendtrafficpolicy_test.go index 58a319a840..30358fee0a 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -2706,6 +2706,114 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { "inline must be set for type Inline", }, }, + { + desc: "response header match in response override", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + ResponseHeaders: []egv1a1.ResponseOverrideHeaderMatch{ + { + Name: "X-Custom-Header", + Value: egv1a1.StringMatch{ + Type: new(egv1a1.StringMatchExact), + Value: "custom-value", + }, + }, + }, + }, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "status code and response header match in response override", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{ + StatusCodes: []egv1a1.StatusCodeMatch{ + { + Value: new(503), + }, + }, + ResponseHeaders: []egv1a1.ResponseOverrideHeaderMatch{ + { + Name: "X-Error-Type", + Value: egv1a1.StringMatch{ + Type: new(egv1a1.StringMatchPrefix), + Value: "upstream-", + }, + }, + }, + }, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "neither status code nor response header in response override", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("eg"), + }, + }, + }, + ResponseOverride: []*egv1a1.ResponseOverride{ + { + Match: egv1a1.CustomResponseMatch{}, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "at least one of statusCodes or responseHeaders must be specified", + }, + }, { desc: "both targetref and targetrefs specified", mutate: func(btp *egv1a1.BackendTrafficPolicy) { diff --git a/test/e2e/testdata/response-override.yaml b/test/e2e/testdata/response-override.yaml index 2bcebcf76a..6c8499ab3f 100644 --- a/test/e2e/testdata/response-override.yaml +++ b/test/e2e/testdata/response-override.yaml @@ -103,3 +103,18 @@ spec: path: type: ReplaceFullPath replaceFullPath: /status/301 + - match: + responseHeaders: + - name: X-Custom-Header + value: + type: Exact + value: custom-value + response: + contentType: text/plain + body: + type: Inline + inline: "matched on response header" + header: + set: + - name: X-Override-Matched + value: "true" diff --git a/test/e2e/tests/direct-response.go b/test/e2e/tests/direct-response.go index 30f10db58b..a60c722cf5 100644 --- a/test/e2e/tests/direct-response.go +++ b/test/e2e/tests/direct-response.go @@ -34,23 +34,39 @@ var DirectResponseTest = suite.ConformanceTest{ kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) // Test inline response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/inline", "text/plain", "GET Oops! Your request is not found.", 200, map[string]string{ - "X-Add-Header": "added-value", - "X-Set-Header": "set-value", - "X-Response-Type": "direct", - "Cache-Control": "no-cache", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/inline", + contentType: "text/plain", + body: "GET Oops! Your request is not found.", + statusCode: 200, + headers: map[string]string{ + "X-Add-Header": "added-value", + "X-Set-Header": "set-value", + "X-Response-Type": "direct", + "Cache-Control": "no-cache", + }, }) // Test value-ref response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/value-ref", "application/json", `{"error": "Internal Server Error"}`, 200, map[string]string{ - "X-Add-Header": "added-json", - "X-Set-Header": "set-json", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/value-ref", + contentType: "application/json", + body: `{"error": "Internal Server Error"}`, + statusCode: 200, + headers: map[string]string{ + "X-Add-Header": "added-json", + "X-Set-Header": "set-json", + }, }) // Test status-only response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/401", "", ``, 401, map[string]string{ - "X-Add-Header": "added-401", - "X-Set-Header": "set-401", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/401", + statusCode: 401, + headers: map[string]string{ + "X-Add-Header": "added-401", + "X-Set-Header": "set-401", + }, }) }) }, diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go index e6d8eb4048..5695f76e6c 100644 --- a/test/e2e/tests/response-override.go +++ b/test/e2e/tests/response-override.go @@ -50,32 +50,89 @@ var ResponseOverrideTest = suite.ConformanceTest{ BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "response-override", Namespace: ns}, suite.ControllerName, ancestorRef) // Test 404 response override with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/404", "text/plain", "404 Oops! Your request is not found.", 404, map[string]string{ - "X-Add-Header": "added-404", - "X-Set-Header": "set-404", - "X-Error-Type": "not-found", - "Cache-Control": "no-cache", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/status/404", + contentType: "text/plain", + body: "404 Oops! Your request is not found.", + statusCode: 404, + headers: map[string]string{ + "X-Add-Header": "added-404", + "X-Set-Header": "set-404", + "X-Error-Type": "not-found", + "Cache-Control": "no-cache", + }, }) // Test 500 response override with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/500", "application/json", `{"error": "Internal Server Error"}`, 500, map[string]string{ - "X-Add-Header": "added-500", - "X-Set-Header": "set-500", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/status/500", + contentType: "application/json", + body: `{"error": "Internal Server Error"}`, + statusCode: 500, + headers: map[string]string{ + "X-Add-Header": "added-500", + "X-Set-Header": "set-500", + }, }) // Test 403 response override with add and set headers (status override to 404) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/403", "", "", 404, map[string]string{ - "X-Add-Header": "added-403", - "X-Set-Header": "set-403", + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/status/403", + statusCode: 404, + headers: map[string]string{ + "X-Add-Header": "added-403", + "X-Set-Header": "set-403", + }, + }) + + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/status/401", + statusCode: 301, + }) + + // Test header match response override and add X-Override-Matched header (body is also overriden) + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/response-override-header-match", + requestHeaders: map[string]string{"X-Echo-Set-Header": "X-Custom-Header: custom-value"}, + contentType: "text/plain", + body: "matched on response header", + statusCode: 200, + headers: map[string]string{"X-Override-Matched": "true"}, + }) + + // Test header match response override NOT doing anything because teh header does not match + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/response-override-header-match", + requestHeaders: map[string]string{"X-Echo-Set-Header": "X-Custom-Header: other-value"}, + contentType: "application/json", + body: "matched on response header", + bodyNotEqual: true, + statusCode: 200, + }) + + // Test header match response override NOT doing anything because teh header is never set + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + path: "/response-override-header-match", + contentType: "application/json", + body: "matched on response header", + bodyNotEqual: true, + statusCode: 200, }) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/401", "", "", 301) }) }, } -func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr, - path, expectedContentType, expectedBody string, expectedStatusCode int, expectedHeaders ...map[string]string, -) { +type expectedResponse struct { + path string + requestHeaders map[string]string + contentType string + body string + bodyNotEqual bool + statusCode int + headers map[string]string +} + +func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr string, expected expectedResponse) { if timeoutConfig == nil { t.Fatalf("timeoutConfig cannot be nil") } @@ -83,48 +140,57 @@ func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwA reqURL := url.URL{ Scheme: "http", Host: httputils.CalculateHost(t, gwAddr, "http"), - Path: path, + Path: expected.path, } httputils.AwaitConvergence(t, timeoutConfig.RequiredConsecutiveSuccesses, timeoutConfig.MaxTimeToConsistency, func(_ time.Duration) bool { - rsp, err := http.Get(reqURL.String()) + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) if err != nil { - tlog.Logf(t, "failed to get response: %v", err) + tlog.Logf(t, "failed to create request: %v", err) return false } + for k, v := range expected.requestHeaders { + req.Header.Set(k, v) + } - // Verify that the response body is overridden - defer rsp.Body.Close() - body, err := io.ReadAll(rsp.Body) + rsp, err := http.DefaultClient.Do(req) if err != nil { - tlog.Logf(t, "failed to read response body: %v", err) + tlog.Logf(t, "failed to get response: %v", err) return false } - if string(body) != expectedBody { - tlog.Logf(t, "expected response body to be %s but got %s", expectedBody, string(body)) + + if expected.statusCode != rsp.StatusCode { + tlog.Logf(t, "expected status code to be %d but got %d", expected.statusCode, rsp.StatusCode) return false } // Verify that the content type is overridden - contentType := rsp.Header.Get("Content-Type") - if contentType != expectedContentType { - tlog.Logf(t, "expected content type to be %s but got %s", expectedContentType, contentType) + if contentType := rsp.Header.Get("Content-Type"); contentType != expected.contentType { + tlog.Logf(t, "expected content type to be %s but got %s", expected.contentType, contentType) + return false + } + + // Verify that the response body is (or, when bodyNotEqual is set, is not) overridden + defer rsp.Body.Close() + body, err := io.ReadAll(rsp.Body) + if err != nil { + tlog.Logf(t, "failed to read response body: %v", err) return false } - if expectedStatusCode != rsp.StatusCode { - tlog.Logf(t, "expected status code to be %d but got %d", expectedStatusCode, rsp.StatusCode) + if expected.bodyNotEqual && string(body) == expected.body{ + tlog.Logf(t, "expected response body NOT to be %s, but it was", expected.body) + return false + } else if string(body) != expected.body { + tlog.Logf(t, "expected response body to be %s but got %s", expected.body, string(body)) return false } // Verify expected headers if provided - if len(expectedHeaders) > 0 { - for headerName, expectedValue := range expectedHeaders[0] { - actualValue := rsp.Header.Get(headerName) - if actualValue != expectedValue { - tlog.Logf(t, "expected header %s to be %s but got %s", headerName, expectedValue, actualValue) - return false - } + for headerName, expectedValue := range expected.headers { + if actualValue := rsp.Header.Get(headerName); actualValue != expectedValue { + tlog.Logf(t, "expected header %s to be %s but got %s", headerName, expectedValue, actualValue) + return false } } diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 598219a0d0..cc54a2b4f6 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -26961,6 +26961,51 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if any of the matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -27016,9 +27061,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index e02760641e..1976ee2e6d 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -2899,6 +2899,51 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if any of the matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2954,9 +2999,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index 4d7abb77c4..3069cedb1f 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -2899,6 +2899,51 @@ spec: match: description: Match configuration. properties: + responseHeaders: + description: Response headers to match on. The match evaluates + to true if any of the matches are successful. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching a response header. + properties: + name: + description: |- + Name of the HTTP header. + The header name is case-insensitive. + For example, "Foo" and "foo" are considered the same header. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value within the HTTP header to match + against. + properties: + type: + default: Exact + description: Type specifies how to match against + a string. + enum: + - Exact + - Prefix + - Suffix + - RegularExpression + type: string + value: + description: Value specifies the string value + that the match must have. + maxLength: 1024 + minLength: 1 + type: string + required: + - value + type: object + required: + - name + - value + type: object + maxItems: 16 + minItems: 1 + type: array statusCodes: description: Status code to match on. The match evaluates to true if any of the matches are successful. @@ -2954,9 +2999,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or responseHeaders must + be specified + rule: has(self.statusCodes) || has(self.responseHeaders) redirect: description: Redirect configuration properties: From d46e214634499e48d7ce373eafa8b80015c10d78 Mon Sep 17 00:00:00 2001 From: kabbohus Date: Thu, 25 Jun 2026 11:16:22 +0200 Subject: [PATCH 2/9] Fix linter error by using pointer for big type Signed-off-by: kabbohus --- test/e2e/tests/direct-response.go | 6 ++--- test/e2e/tests/response-override.go | 36 ++++++++++++++--------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/test/e2e/tests/direct-response.go b/test/e2e/tests/direct-response.go index a60c722cf5..feb6c32d65 100644 --- a/test/e2e/tests/direct-response.go +++ b/test/e2e/tests/direct-response.go @@ -34,7 +34,7 @@ var DirectResponseTest = suite.ConformanceTest{ kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) // Test inline response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/inline", contentType: "text/plain", body: "GET Oops! Your request is not found.", @@ -48,7 +48,7 @@ var DirectResponseTest = suite.ConformanceTest{ }) // Test value-ref response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/value-ref", contentType: "application/json", body: `{"error": "Internal Server Error"}`, @@ -60,7 +60,7 @@ var DirectResponseTest = suite.ConformanceTest{ }) // Test status-only response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/401", statusCode: 401, headers: map[string]string{ diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go index 5695f76e6c..161efee815 100644 --- a/test/e2e/tests/response-override.go +++ b/test/e2e/tests/response-override.go @@ -50,7 +50,7 @@ var ResponseOverrideTest = suite.ConformanceTest{ BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "response-override", Namespace: ns}, suite.ControllerName, ancestorRef) // Test 404 response override with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/status/404", contentType: "text/plain", body: "404 Oops! Your request is not found.", @@ -64,7 +64,7 @@ var ResponseOverrideTest = suite.ConformanceTest{ }) // Test 500 response override with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/status/500", contentType: "application/json", body: `{"error": "Internal Server Error"}`, @@ -76,7 +76,7 @@ var ResponseOverrideTest = suite.ConformanceTest{ }) // Test 403 response override with add and set headers (status override to 404) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/status/403", statusCode: 404, headers: map[string]string{ @@ -85,13 +85,13 @@ var ResponseOverrideTest = suite.ConformanceTest{ }, }) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/status/401", statusCode: 301, }) - // Test header match response override and add X-Override-Matched header (body is also overriden) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + // Test header match response override and add X-Override-Matched header (body is also overridden) + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/response-override-header-match", requestHeaders: map[string]string{"X-Echo-Set-Header": "X-Custom-Header: custom-value"}, contentType: "text/plain", @@ -100,8 +100,8 @@ var ResponseOverrideTest = suite.ConformanceTest{ headers: map[string]string{"X-Override-Matched": "true"}, }) - // Test header match response override NOT doing anything because teh header does not match - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + // Test header match response override NOT doing anything because the header does not match + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/response-override-header-match", requestHeaders: map[string]string{"X-Echo-Set-Header": "X-Custom-Header: other-value"}, contentType: "application/json", @@ -110,8 +110,8 @@ var ResponseOverrideTest = suite.ConformanceTest{ statusCode: 200, }) - // Test header match response override NOT doing anything because teh header is never set - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, expectedResponse{ + // Test header match response override NOT doing anything because the header is never set + verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, &expectedResponse{ path: "/response-override-header-match", contentType: "application/json", body: "matched on response header", @@ -123,16 +123,16 @@ var ResponseOverrideTest = suite.ConformanceTest{ } type expectedResponse struct { - path string + path string requestHeaders map[string]string - contentType string - body string - bodyNotEqual bool - statusCode int - headers map[string]string + contentType string + body string + bodyNotEqual bool + statusCode int + headers map[string]string } -func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr string, expected expectedResponse) { +func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr string, expected *expectedResponse) { if timeoutConfig == nil { t.Fatalf("timeoutConfig cannot be nil") } @@ -178,7 +178,7 @@ func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwA return false } - if expected.bodyNotEqual && string(body) == expected.body{ + if expected.bodyNotEqual && string(body) == expected.body { tlog.Logf(t, "expected response body NOT to be %s, but it was", expected.body) return false } else if string(body) != expected.body { From 36c35b6532f054d6a69ac9b171e94273b31acbb3 Mon Sep 17 00:00:00 2001 From: kabbohus Date: Thu, 25 Jun 2026 11:31:41 +0200 Subject: [PATCH 3/9] Fix copy-paste error resulting in always adding a empty item in response headers Signed-off-by: kabbohus --- internal/xds/translator/custom_response.go | 2 +- .../out/xds-ir/custom-response.listeners.yaml | 18 ++++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go index e85ae0ae18..a4db7f6bba 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -225,7 +225,7 @@ func (c *customResponse) buildResponseHeaderPredicate(headers []ir.ResponseOverr }, }, }, - }, nil) + }) } if len(predicates) == 1 { 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 index 36e7c1890e..7aeece8488 100644 --- a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml @@ -170,16 +170,14 @@ key: Content-Type value: text/plain predicate: - andMatcher: - predicate: - - singlePredicate: - input: - name: http-response-header-match-input - typedConfig: - '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput - headerName: X-Custom-Header - valueMatch: - exact: custom-value + singlePredicate: + input: + name: http-response-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseHeaderMatchInput + headerName: X-Custom-Header + valueMatch: + exact: custom-value - onMatch: action: name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/2 From 3906cc3a474152a794560dcc99ce5a6798fb400a Mon Sep 17 00:00:00 2001 From: kabbohus Date: Thu, 25 Jun 2026 12:37:49 +0200 Subject: [PATCH 4/9] Fix log error in verification response verification Signed-off-by: kabbohus --- test/e2e/tests/response-override.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go index 161efee815..57e2e6f488 100644 --- a/test/e2e/tests/response-override.go +++ b/test/e2e/tests/response-override.go @@ -178,9 +178,11 @@ func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwA return false } - if expected.bodyNotEqual && string(body) == expected.body { - tlog.Logf(t, "expected response body NOT to be %s, but it was", expected.body) - return false + if expected.bodyNotEqual { + if string(body) == expected.body { + tlog.Logf(t, "expected response body NOT to be %s", expected.body) + return false + } } else if string(body) != expected.body { tlog.Logf(t, "expected response body to be %s but got %s", expected.body, string(body)) return false From c5fc326bfe525aa819f75afd1b577af2f19059c1 Mon Sep 17 00:00:00 2001 From: kabbohus Date: Thu, 25 Jun 2026 13:04:35 +0200 Subject: [PATCH 5/9] Header matching is or'd and not and'd Signed-off-by: kabbohus --- internal/xds/translator/custom_response.go | 4 ++-- .../testdata/out/xds-ir/custom-response.listeners.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go index a4db7f6bba..f3f25f656e 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -233,8 +233,8 @@ func (c *customResponse) buildResponseHeaderPredicate(headers []ir.ResponseOverr } return &matcherv3.Matcher_MatcherList_Predicate{ - MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ - AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ + OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ Predicate: predicates, }, }, 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 index 7aeece8488..0e09df1626 100644 --- a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml @@ -200,7 +200,7 @@ '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput valueMatch: exact: "503" - - andMatcher: + - orMatcher: predicate: - singlePredicate: input: From faa38a49e3a688c52c953f9d494a2d86116fffde Mon Sep 17 00:00:00 2001 From: kabbohus Date: Wed, 29 Jul 2026 09:14:17 +0200 Subject: [PATCH 6/9] Use correct PR number in release notes Signed-off-by: kabbohus --- ...onse-header-in-response-override-with-backendtrafficpolicy.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename release-notes/current/new_features/{0000-support-response-header-in-response-override-with-backendtrafficpolicy.md => 9354-support-response-header-in-response-override-with-backendtrafficpolicy.md} (100%) diff --git a/release-notes/current/new_features/0000-support-response-header-in-response-override-with-backendtrafficpolicy.md b/release-notes/current/new_features/9354-support-response-header-in-response-override-with-backendtrafficpolicy.md similarity index 100% rename from release-notes/current/new_features/0000-support-response-header-in-response-override-with-backendtrafficpolicy.md rename to release-notes/current/new_features/9354-support-response-header-in-response-override-with-backendtrafficpolicy.md From 4b7c622d3b3fb6941f222348e9dc18d0528baa0f Mon Sep 17 00:00:00 2001 From: kabbohus Date: Mon, 3 Aug 2026 14:32:10 +0200 Subject: [PATCH 7/9] Use HTTPHeaderName instead of plain string for header name type Signed-off-by: kabbohus --- api/v1alpha1/shared_types.go | 5 +---- .../gateway.envoyproxy.io_backendtrafficpolicies.yaml | 1 + .../gateway.envoyproxy.io_backendtrafficpolicies.yaml | 1 + internal/gatewayapi/backendtrafficpolicy.go | 4 ++-- site/content/en/latest/api/extension_types.md | 2 +- test/helm/gateway-crds-helm/all.out.yaml | 1 + test/helm/gateway-crds-helm/e2e.out.yaml | 1 + test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml | 1 + 8 files changed, 9 insertions(+), 7 deletions(-) diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index c22c380a6f..a38e19f5c0 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -842,10 +842,7 @@ type ResponseOverrideHeaderMatch struct { // Name of the HTTP header. // The header name is case-insensitive. // For example, "Foo" and "foo" are considered the same header. - // - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=256 - Name string `json:"name"` + Name gwapiv1.HTTPHeaderName `json:"name"` // Value within the HTTP header to match against. Value StringMatch `json:"value"` diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index a50196f404..6e6b7f6be5 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2389,6 +2389,7 @@ spec: For example, "Foo" and "foo" are considered the same header. maxLength: 256 minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ type: string value: description: Value within the HTTP header to match diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 3a2d0a095b..b12f22f9ad 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2388,6 +2388,7 @@ spec: For example, "Foo" and "foo" are considered the same header. maxLength: 256 minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ type: string value: description: Value within the HTTP header to match diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index ec188149f0..05ea4e8fe5 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -2528,8 +2528,8 @@ func (t *Translator) buildResponseOverride(policy *egv1a1.BackendTrafficPolicy, for _, h := range ro.Match.ResponseHeaders { match.ResponseHeaders = append(match.ResponseHeaders, ir.ResponseOverrideHeaderMatch{ - Name: h.Name, - Value: *irStringMatch(h.Name, h.Value), + Name: string(h.Name), + Value: *irStringMatch(string(h.Name), h.Value), }) } diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 52a90a0d80..89582660bc 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -5774,7 +5774,7 @@ _Appears in:_ | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | -| `name` | _string_ | true | | Name of the HTTP header.
The header name is case-insensitive.
For example, "Foo" and "foo" are considered the same header. | +| `name` | _[HTTPHeaderName](#httpheadername)_ | true | | Name of the HTTP header.
The header name is case-insensitive.
For example, "Foo" and "foo" are considered the same header. | | `value` | _[StringMatch](#stringmatch)_ | true | | Value within the HTTP header to match against. | diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 226859045b..36375aec4c 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -27006,6 +27006,7 @@ spec: For example, "Foo" and "foo" are considered the same header. maxLength: 256 minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ type: string value: description: Value within the HTTP header to match diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index fc955ab2ad..1d129f2eaf 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -2944,6 +2944,7 @@ spec: For example, "Foo" and "foo" are considered the same header. maxLength: 256 minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ type: string value: description: Value within the HTTP header to match diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index 4903228a57..fb92f411df 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -2944,6 +2944,7 @@ spec: For example, "Foo" and "foo" are considered the same header. maxLength: 256 minLength: 1 + pattern: ^[A-Za-z0-9!#$%&'*+\-.^_\x60|~]+$ type: string value: description: Value within the HTTP header to match From a0ea26477de04d668efbec313c0e0d1db49a4ee3 Mon Sep 17 00:00:00 2001 From: kabbohus Date: Tue, 4 Aug 2026 13:09:49 +0200 Subject: [PATCH 8/9] Remove new xds type in favor of StringMatch Signed-off-by: kabbohus --- internal/gatewayapi/backendtrafficpolicy.go | 5 +--- ...fficpolicy-with-response-override.out.yaml | 24 +++++++------------ internal/ir/xds.go | 12 +--------- internal/ir/zz_generated.deepcopy.go | 18 +------------- internal/xds/translator/custom_response.go | 4 ++-- .../testdata/in/xds-ir/custom-response.yaml | 12 +++------- 6 files changed, 17 insertions(+), 58 deletions(-) diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index 05ea4e8fe5..8985819373 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -2527,10 +2527,7 @@ func (t *Translator) buildResponseOverride(policy *egv1a1.BackendTrafficPolicy, } for _, h := range ro.Match.ResponseHeaders { - match.ResponseHeaders = append(match.ResponseHeaders, ir.ResponseOverrideHeaderMatch{ - Name: string(h.Name), - Value: *irStringMatch(string(h.Name), h.Value), - }) + match.ResponseHeaders = append(match.ResponseHeaders, *irStringMatch(string(h.Name), h.Value)) } if ro.Redirect != nil { diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml index a81c6c6643..5d8bd18b22 100644 --- a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override.out.yaml @@ -741,27 +741,21 @@ xdsIR: statusCode: 401 - match: responseHeaders: - - name: X-Custom-Header - value: - distinct: false - exact: custom-value - name: X-Custom-Header + - distinct: false + exact: custom-value + name: X-Custom-Header name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/1 response: body: bWF0Y2hlZCByZXNwb25zZSBoZWFkZXI= contentType: text/plain - match: responseHeaders: - - name: X-Error-Type - value: - distinct: false - name: X-Error-Type - prefix: upstream- - - name: X-Region - value: - distinct: false - name: X-Region - safeRegex: ^us-.*$ + - distinct: false + name: X-Error-Type + prefix: upstream- + - distinct: false + name: X-Region + safeRegex: ^us-.*$ statusCodes: - value: 503 name: backendtrafficpolicy/default/policy-for-route-2/responseoverride/rule/2 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index b6c283da16..9f6f7d20f7 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -834,17 +834,7 @@ type CustomResponseMatch struct { StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` // Response headers to match on. The match evaluates to true if any of the matches are successful. - ResponseHeaders []ResponseOverrideHeaderMatch `json:"responseHeaders,omitempty"` -} - -// ResponseOverrideHeaderMatch defines the configuration for matching a response header. -// +k8s:deepcopy-gen=true -type ResponseOverrideHeaderMatch struct { - // Name of the HTTP header. The header name is case-insensitive. - Name string `json:"name"` - - // Value within the HTTP header to match against. - Value StringMatch `json:"value"` + ResponseHeaders []StringMatch `json:"responseHeaders,omitempty"` } // StatusCodeMatch defines the configuration for matching a status code. diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 98f5441c68..a2029662ca 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1287,7 +1287,7 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { } if in.ResponseHeaders != nil { in, out := &in.ResponseHeaders, &out.ResponseHeaders - *out = make([]ResponseOverrideHeaderMatch, len(*in)) + *out = make([]StringMatch, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -4298,22 +4298,6 @@ func (in *ResponseOverride) DeepCopy() *ResponseOverride { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ResponseOverrideHeaderMatch) DeepCopyInto(out *ResponseOverrideHeaderMatch) { - *out = *in - in.Value.DeepCopyInto(&out.Value) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideHeaderMatch. -func (in *ResponseOverrideHeaderMatch) DeepCopy() *ResponseOverrideHeaderMatch { - if in == nil { - return nil - } - out := new(ResponseOverrideHeaderMatch) - 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 diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go index f3f25f656e..f46383322e 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -205,14 +205,14 @@ func (c *customResponse) buildStatusCodePredicate(codes []ir.StatusCodeMatch) (* }, nil } -func (c *customResponse) buildResponseHeaderPredicate(headers []ir.ResponseOverrideHeaderMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { +func (c *customResponse) buildResponseHeaderPredicate(headers []ir.StringMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { predicates := make([]*matcherv3.Matcher_MatcherList_Predicate, 0, len(headers)) for _, header := range headers { input, err := c.buildResponseHeaderInput(header.Name) if err != nil { return nil, err } - valueMatcher, err := buildStringMatcher(header.Value) + valueMatcher, err := buildStringMatcher(header) if err != nil { return nil, err } diff --git a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml index 3ae9894428..a3487ffdf4 100644 --- a/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml +++ b/internal/xds/translator/testdata/in/xds-ir/custom-response.yaml @@ -101,9 +101,7 @@ http: - match: responseHeaders: - name: X-Custom-Header - value: - name: X-Custom-Header - exact: custom-value + exact: custom-value name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/1 response: body: bWF0Y2hlZCByZXNwb25zZSBoZWFkZXI= @@ -113,13 +111,9 @@ http: - value: 503 responseHeaders: - name: X-Error-Type - value: - name: X-Error-Type - prefix: upstream- + prefix: upstream- - name: X-Region - value: - name: X-Region - safeRegex: "^us-.*$" + safeRegex: "^us-.*$" name: backendtrafficpolicy/default/policy-for-route/responseoverride/rule/2 response: body: eyJlcnJvciI6ImNvbWJpbmVkIG1hdGNoIn0= From a65af4da5dc2c62ec6e5b9bd46c586b4589b59d8 Mon Sep 17 00:00:00 2001 From: kabbohus Date: Tue, 4 Aug 2026 13:35:23 +0200 Subject: [PATCH 9/9] Make response override headers AND'd per rule so that both AND + OR predicates could be expressed in the CR Signed-off-by: kabbohus --- api/v1alpha1/shared_types.go | 2 +- .../gateway.envoyproxy.io_backendtrafficpolicies.yaml | 2 +- .../gateway.envoyproxy.io_backendtrafficpolicies.yaml | 2 +- internal/ir/xds.go | 2 +- internal/xds/translator/custom_response.go | 4 ++-- .../testdata/out/xds-ir/custom-response.listeners.yaml | 2 +- site/content/en/latest/api/extension_types.md | 2 +- test/helm/gateway-crds-helm/all.out.yaml | 2 +- test/helm/gateway-crds-helm/e2e.out.yaml | 2 +- test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index a38e19f5c0..ae09dacde7 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -829,7 +829,7 @@ type CustomResponseMatch struct { // +kubebuilder:validation:MaxItems=50 StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` - // Response headers to match on. The match evaluates to true if any of the matches are successful. + // Response headers to match on. The match evaluates to true if all matches are successful. // // +optional // +kubebuilder:validation:MinItems=1 diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 21abf9efa1..a407377cdc 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2384,7 +2384,7 @@ spec: properties: responseHeaders: description: Response headers to match on. The match evaluates - to true if any of the matches are successful. + to true if all matches are successful. items: description: ResponseOverrideHeaderMatch defines the configuration for matching a response header. diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 6331c684e4..616745119d 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -2383,7 +2383,7 @@ spec: properties: responseHeaders: description: Response headers to match on. The match evaluates - to true if any of the matches are successful. + to true if all matches are successful. items: description: ResponseOverrideHeaderMatch defines the configuration for matching a response header. diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 9f6f7d20f7..bf94dc20df 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -833,7 +833,7 @@ type CustomResponseMatch struct { // Status code to match on. The match evaluates to true if any of the matches are successful. StatusCodes []StatusCodeMatch `json:"statusCodes,omitempty"` - // Response headers to match on. The match evaluates to true if any of the matches are successful. + // Response headers to match on. The match evaluates to true if all matches are successful. ResponseHeaders []StringMatch `json:"responseHeaders,omitempty"` } diff --git a/internal/xds/translator/custom_response.go b/internal/xds/translator/custom_response.go index f46383322e..4373d712e7 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -233,8 +233,8 @@ func (c *customResponse) buildResponseHeaderPredicate(headers []ir.StringMatch) } return &matcherv3.Matcher_MatcherList_Predicate{ - MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ - OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ + AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ Predicate: predicates, }, }, 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 index 2ec479f42b..79262595d8 100644 --- a/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response.listeners.yaml @@ -200,7 +200,7 @@ '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput valueMatch: exact: "503" - - orMatcher: + - andMatcher: predicate: - singlePredicate: input: diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index e98c8208e4..1c45c12c9b 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1392,7 +1392,7 @@ _Appears in:_ | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `statusCodes` | _[StatusCodeMatch](#statuscodematch) array_ | false | | Status code to match on. The match evaluates to true if any of the matches are successful. | -| `responseHeaders` | _[ResponseOverrideHeaderMatch](#responseoverrideheadermatch) array_ | false | | Response headers to match on. The match evaluates to true if any of the matches are successful. | +| `responseHeaders` | _[ResponseOverrideHeaderMatch](#responseoverrideheadermatch) array_ | false | | Response headers to match on. The match evaluates to true if all matches are successful. | #### CustomTag diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 0d39fd5e64..4e5ee99f20 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -27001,7 +27001,7 @@ spec: properties: responseHeaders: description: Response headers to match on. The match evaluates - to true if any of the matches are successful. + to true if all matches are successful. items: description: ResponseOverrideHeaderMatch defines the configuration for matching a response header. diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index 646fb2feb0..e91a01cb22 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -2939,7 +2939,7 @@ spec: properties: responseHeaders: description: Response headers to match on. The match evaluates - to true if any of the matches are successful. + to true if all matches are successful. items: description: ResponseOverrideHeaderMatch defines the configuration for matching a response header. diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index c33ce53fdb..7341d49608 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -2939,7 +2939,7 @@ spec: properties: responseHeaders: description: Response headers to match on. The match evaluates - to true if any of the matches are successful. + to true if all matches are successful. items: description: ResponseOverrideHeaderMatch defines the configuration for matching a response header.