diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index 5450cb4913..db31b1e276 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -792,11 +792,37 @@ type ResponseOverride struct { } // CustomResponseMatch defines the configuration for matching a user response to return a custom one. +// At least one of statusCodes or requestHeaders must be specified. +// When multiple criteria are specified, all of them must match (logical AND) for the rule to apply. +// +kubebuilder:validation:XValidation:rule="has(self.statusCodes) || has(self.requestHeaders)",message="at least one of statusCodes or requestHeaders 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"` + + // RequestHeaders to match on. All of the specified headers must match (logical AND) + // for the rule to apply. Matching is performed against request headers observed on + // the response path of the stream. + // + // +optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=16 + RequestHeaders []ResponseOverrideHeaderMatch `json:"requestHeaders,omitempty"` +} + +// ResponseOverrideHeaderMatch defines the configuration for matching an HTTP header. +type ResponseOverrideHeaderMatch struct { + // Name of the HTTP header to match against. The header name is case-insensitive. + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + Name string `json:"name"` + + // Value specifies how to match against the value of the header. + 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 746c6c732f..a681252e40 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1922,6 +1922,13 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.RequestHeaders != nil { + in, out := &in.RequestHeaders, &out.RequestHeaders + *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. @@ -7474,6 +7481,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 3e6bd0186c..7c31379d85 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 @@ -2138,6 +2138,51 @@ spec: match: description: Match configuration. properties: + requestHeaders: + description: |- + RequestHeaders to match on. All of the specified headers must match (logical AND) + for the rule to apply. Matching is performed against request headers observed on + the response path of the stream. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching an HTTP header. + properties: + name: + description: Name of the HTTP header to match against. + The header name is case-insensitive. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value specifies how to match against + the value of the header. + 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. @@ -2193,9 +2238,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or requestHeaders must + be specified + rule: has(self.statusCodes) || has(self.requestHeaders) 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 a6ec33c52f..559300b843 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 @@ -2137,6 +2137,51 @@ spec: match: description: Match configuration. properties: + requestHeaders: + description: |- + RequestHeaders to match on. All of the specified headers must match (logical AND) + for the rule to apply. Matching is performed against request headers observed on + the response path of the stream. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching an HTTP header. + properties: + name: + description: Name of the HTTP header to match against. + The header name is case-insensitive. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value specifies how to match against + the value of the header. + 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. @@ -2192,9 +2237,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or requestHeaders must + be specified + rule: has(self.statusCodes) || has(self.requestHeaders) redirect: description: Redirect configuration properties: diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index 036b9de0ef..222d48717a 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -1777,6 +1777,14 @@ func (t *Translator) buildResponseOverride(policy *egv1a1.BackendTrafficPolicy) } } + for _, h := range ro.Match.RequestHeaders { + sm := irStringMatch(h.Name, h.Value) + match.RequestHeaders = append(match.RequestHeaders, ir.ResponseOverrideHeaderMatch{ + Name: h.Name, + Value: *sm, + }) + } + if ro.Redirect != nil { redirect := &ir.Redirect{ Scheme: ro.Redirect.Scheme, diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-request-header-match.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-request-header-match.in.yaml new file mode 100644 index 0000000000..0387ef6319 --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-request-header-match.in.yaml @@ -0,0 +1,70 @@ +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 +httpRoutes: + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - foo.envoyproxy.io + parentRefs: + - namespace: default + name: gateway-1 + sectionName: http + rules: + - matches: + - path: + value: "/" + backendRefs: + - name: service-1 + port: 8080 +backendTrafficPolicies: + - apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + namespace: default + name: policy-for-route-1 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + responseOverride: + - match: + statusCodes: + - value: 404 + requestHeaders: + - name: Accept + value: + type: Exact + value: application/json + response: + contentType: application/json + body: + type: Inline + inline: | + {"error":"not found"} + - match: + statusCodes: + - value: 404 + response: + contentType: text/html + body: + type: Inline + inline: | +

Not Found

diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-request-header-match.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-request-header-match.out.yaml new file mode 100644 index 0000000000..b187a9407d --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-response-override-request-header-match.out.yaml @@ -0,0 +1,259 @@ +backendTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + name: policy-for-route-1 + namespace: default + spec: + responseOverride: + - match: + requestHeaders: + - name: Accept + value: + type: Exact + value: application/json + statusCodes: + - type: null + value: 404 + response: + body: + inline: | + {"error":"not found"} + type: Inline + contentType: application/json + - match: + statusCodes: + - type: null + value: 404 + response: + body: + inline: | +

Not Found

+ type: Inline + contentType: text/html + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: default + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: spec.targetRef is deprecated, use spec.targetRefs instead + reason: DeprecatedField + status: "True" + type: Warning + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + 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 +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: httproute-1 + namespace: default + spec: + hostnames: + - foo.envoyproxy.io + parentRefs: + - name: gateway-1 + 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-1 + namespace: default + sectionName: http +infraIR: + default/gateway-1: + proxy: + listeners: + - 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 + ownerReference: + kind: GatewayClass + name: envoy-gateway-class + name: default/gateway-1 + namespace: envoy-gateway-system +xdsIR: + default/gateway-1: + accessLog: + json: + - path: /dev/stdout + globalResources: + proxyServiceCluster: + metadata: + kind: Service + name: envoy-default-gateway-1-bfd08ef4 + namespace: envoy-gateway-system + sectionName: "8080" + name: default/gateway-1 + settings: + - addressType: IP + endpoints: + - host: 7.6.5.4 + port: 8080 + zone: zone1 + metadata: + kind: Service + name: envoy-default-gateway-1-bfd08ef4 + namespace: envoy-gateway-system + sectionName: "8080" + name: default/gateway-1 + protocol: TCP + http: + - address: 0.0.0.0 + externalPort: 80 + hostnames: + - '*' + metadata: + kind: Gateway + name: gateway-1 + namespace: default + sectionName: http + name: default/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - destination: + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + metadata: + kind: Service + name: service-1 + namespace: default + sectionName: "8080" + name: httproute/default/httproute-1/rule/0/backend/0 + protocol: HTTP + weight: 1 + hostname: foo.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + policies: + - kind: BackendTrafficPolicy + name: policy-for-route-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/foo_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / + traffic: + responseOverride: + name: backendtrafficpolicy/default/policy-for-route-1 + rules: + - match: + requestHeaders: + - name: Accept + value: + distinct: false + exact: application/json + name: Accept + statusCodes: + - value: 404 + name: backendtrafficpolicy/default/policy-for-route-1/responseoverride/rule/0 + response: + body: eyJlcnJvciI6Im5vdCBmb3VuZCJ9Cg== + contentType: application/json + - match: + statusCodes: + - value: 404 + name: backendtrafficpolicy/default/policy-for-route-1/responseoverride/rule/1 + response: + body: PGh0bWw+PGJvZHk+PGgxPk5vdCBGb3VuZDwvaDE+PC9ib2R5PjwvaHRtbD4K + contentType: text/html + readyListener: + address: 0.0.0.0 + ipFamily: IPv4 + path: /ready + port: 19003 diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 1e778399b1..c48a8591d5 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -723,7 +723,19 @@ type ResponseOverrideRule struct { // +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"` + + // RequestHeaders to match on. All listed headers must match for the rule to apply. + RequestHeaders []ResponseOverrideHeaderMatch `json:"requestHeaders,omitempty"` +} + +// ResponseOverrideHeaderMatch defines the configuration for matching a request header. +// +k8s:deepcopy-gen=true +type ResponseOverrideHeaderMatch struct { + // Name of the HTTP header to match on. + Name string `json:"name"` + // Value specifies how to match against the value of the header. + 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 0ce50fbd5a..58171b8bc4 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -1039,6 +1039,13 @@ func (in *CustomResponseMatch) DeepCopyInto(out *CustomResponseMatch) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.RequestHeaders != nil { + in, out := &in.RequestHeaders, &out.RequestHeaders + *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. @@ -3900,6 +3907,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 a02218a10d..6eb9000248 100644 --- a/internal/xds/translator/custom_response.go +++ b/internal/xds/translator/custom_response.go @@ -30,6 +30,13 @@ import ( "github.com/envoyproxy/gateway/internal/xds/types" ) +const ( + httpRequestHeaderMatchInputName = "http-request-header-match-input" + httpResponseStatusCodeInputName = "http-response-status-code-match-input" + httpAttributesCELMatchInputName = "http-attributes-cel-match-input" + celMatcherName = "cel-matcher" +) + func init() { registerHTTPFilter(&customResponse{}) } @@ -105,62 +112,40 @@ func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3. 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 { + action, err := c.buildAction(&r) + if err != nil { return nil, err } - switch { - case len(r.Match.StatusCodes) == 0: + if len(r.Match.StatusCodes) == 0 && len(r.Match.RequestHeaders) == 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, - }, - } + return nil, fmt.Errorf("response override rule must specify at least one of statusCodes or requestHeaders") + } - matchers = append(matchers, matcher) + // Per-criterion predicates. + // Status codes within a rule OR together; headers within a rule AND together; + // the two groups AND together. + var andParts []*matcherv3.Matcher_MatcherList_Predicate + + if p, err := c.buildStatusCodesPredicate(r.Match.StatusCodes); err != nil { + return nil, err + } else if p != nil { + andParts = append(andParts, p) + } + + if p, err := c.buildRequestHeadersPredicate(r.Match.RequestHeaders); err != nil { + return nil, err + } else if p != nil { + andParts = append(andParts, p) } + predicate := combineAnd(andParts) + matchers = append(matchers, &matcherv3.Matcher_MatcherList_FieldMatcher{ + Predicate: predicate, + OnMatch: &matcherv3.Matcher_OnMatch{ + OnMatch: action, + }, + }) } // Create a MatcherList. @@ -178,6 +163,133 @@ func (c *customResponse) customResponseConfig(ro *ir.ResponseOverride) (*respv3. return cr, nil } +// buildStatusCodesPredicate returns a predicate that matches if any of the given +// status codes match. Returns nil when the list is empty. +func (c *customResponse) buildStatusCodesPredicate(codes []ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + if len(codes) == 0 { + return nil, nil + } + if len(codes) == 1 { + return c.buildSinglePredicate(codes[0]) + } + var predicates []*matcherv3.Matcher_MatcherList_Predicate + for _, codeMatch := range codes { + p, err := c.buildSinglePredicate(codeMatch) + if err != nil { + return nil, err + } + predicates = append(predicates, p) + } + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_OrMatcher{ + OrMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: predicates, + }, + }, + }, nil +} + +// buildRequestHeadersPredicate returns a predicate that matches only if all given +// request headers match. Returns nil when the list is empty. +func (c *customResponse) buildRequestHeadersPredicate(headers []ir.ResponseOverrideHeaderMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + if len(headers) == 0 { + return nil, nil + } + var predicates []*matcherv3.Matcher_MatcherList_Predicate + for _, h := range headers { + p, err := c.buildRequestHeaderPredicate(&h) + if err != nil { + return nil, err + } + predicates = append(predicates, p) + } + 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 (c *customResponse) buildRequestHeaderPredicate(h *ir.ResponseOverrideHeaderMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { + input, err := c.buildRequestHeaderInput(h.Name) + if err != nil { + return nil, err + } + sm, err := buildCncfStringMatcher(h.Value) + if err != nil { + return nil, err + } + return &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: sm, + }, + }, + }, + }, nil +} + +// buildCncfStringMatcher converts an ir.StringMatch into the cncf xds StringMatcher +// used by the generic matcher framework. +func buildCncfStringMatcher(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}, + }, + }, nil + default: + return nil, fmt.Errorf("unsupported string match for header %q", irMatch.Name) + } +} + +func (c *customResponse) buildRequestHeaderInput(headerName string) (*cncfv3.TypedExtensionConfig, error) { + pb, err := proto.ToAnyWithValidation(&envoymatcherv3.HttpRequestHeaderMatchInput{ + HeaderName: headerName, + }) + if err != nil { + return nil, err + } + return &cncfv3.TypedExtensionConfig{ + Name: httpRequestHeaderMatchInputName, + TypedConfig: pb, + }, nil +} + +// combineAnd ANDs together predicates, simplifying when only one is present. +func combineAnd(parts []*matcherv3.Matcher_MatcherList_Predicate) *matcherv3.Matcher_MatcherList_Predicate { + if len(parts) == 1 { + return parts[0] + } + return &matcherv3.Matcher_MatcherList_Predicate{ + MatchType: &matcherv3.Matcher_MatcherList_Predicate_AndMatcher{ + AndMatcher: &matcherv3.Matcher_MatcherList_Predicate_PredicateList{ + Predicate: parts, + }, + }, + } +} + func (c *customResponse) buildSinglePredicate(codeMatch ir.StatusCodeMatch) (*matcherv3.Matcher_MatcherList_Predicate, error) { var ( httpAttributeCELInput *cncfv3.TypedExtensionConfig @@ -240,7 +352,7 @@ func (c *customResponse) buildHTTPAttributeCELInput() (*cncfv3.TypedExtensionCon } return &cncfv3.TypedExtensionConfig{ - Name: "http-attributes-cel-match-input", + Name: httpAttributesCELMatchInputName, TypedConfig: pb, }, nil } @@ -256,7 +368,7 @@ func (c *customResponse) buildStatusCodeInput() (*cncfv3.TypedExtensionConfig, e } return &cncfv3.TypedExtensionConfig{ - Name: "http-response-status-code-match-input", + Name: httpResponseStatusCodeInputName, TypedConfig: pb, }, nil } @@ -363,12 +475,12 @@ func (c *customResponse) buildStatusCodeCELMatcher(codeRange ir.StatusCodeRange) } return &cncfv3.TypedExtensionConfig{ - Name: "cel-matcher", + Name: celMatcherName, TypedConfig: pb, }, 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 @@ -391,7 +503,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{ @@ -419,7 +531,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-request-header-match.yaml b/internal/xds/translator/testdata/in/xds-ir/custom-response-request-header-match.yaml new file mode 100644 index 0000000000..032ca50079 --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/custom-response-request-header-match.yaml @@ -0,0 +1,55 @@ +http: + - address: 0.0.0.0 + hostnames: + - "*" + 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 + name: httproute/default/httproute-1/rule/0/backend/0 + 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-route-1 + rules: + - match: + statusCodes: + - value: 404 + requestHeaders: + - name: Accept + value: + name: Accept + exact: application/json + name: backendtrafficpolicy/default/policy-for-route-1/responseoverride/rule/0 + response: + body: eyJlcnJvciI6Im5vdCBmb3VuZCJ9Cg== + contentType: application/json + - match: + statusCodes: + - value: 404 + name: backendtrafficpolicy/default/policy-for-route-1/responseoverride/rule/1 + response: + body: PGh0bWw+PGJvZHk+PGgxPk5vdCBGb3VuZDwvaDE+PC9ib2R5PjwvaHRtbD4K + contentType: text/html diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.clusters.yaml new file mode 100644 index 0000000000..54d3dffabe --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.clusters.yaml @@ -0,0 +1,23 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-1/rule/0 + ignoreHealthOnHostRemoval: true + loadBalancingPolicy: + policies: + - typedExtensionConfig: + name: envoy.load_balancing_policies.least_request + typedConfig: + '@type': type.googleapis.com/envoy.extensions.load_balancing_policies.least_request.v3.LeastRequest + localityLbConfig: + localityWeightedLbConfig: {} + name: httproute/default/httproute-1/rule/0 + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.endpoints.yaml new file mode 100644 index 0000000000..29bb6b4e44 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.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-request-header-match.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.listeners.yaml new file mode 100644 index 0000000000..faf737e837 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.listeners.yaml @@ -0,0 +1,94 @@ +- 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-route-1 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.custom_response.v3.CustomResponse + customResponseMatcher: + matcherList: + matchers: + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-route-1/responseoverride/rule/0 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + bodyFormat: + textFormat: | + {"error":"not found"} + 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: "404" + - singlePredicate: + input: + name: http-request-header-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput + headerName: Accept + valueMatch: + exact: application/json + - onMatch: + action: + name: backendtrafficpolicy/default/policy-for-route-1/responseoverride/rule/1 + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.custom_response.local_response_policy.v3.LocalResponsePolicy + bodyFormat: + textFormat: | +

Not Found

+ responseHeadersToAdd: + - appendAction: OVERWRITE_IF_EXISTS_OR_ADD + header: + key: Content-Type + value: text/html + predicate: + singlePredicate: + input: + name: http-response-status-code-match-input + typedConfig: + '@type': type.googleapis.com/envoy.type.matcher.v3.HttpResponseStatusCodeMatchInput + valueMatch: + exact: "404" + - 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 + maxConnectionsToAcceptPerSocketEvent: 1 + name: default/gateway-1/http + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.routes.yaml new file mode 100644 index 0000000000..197501055d --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/custom-response-request-header-match.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-route-1: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} diff --git a/release-notes/current.yaml b/release-notes/current.yaml index a77ca0a894..f799c9b00a 100644 --- a/release-notes/current.yaml +++ b/release-notes/current.yaml @@ -46,6 +46,7 @@ new features: | Added support for OpenTelemetry sampler configuration for tracing. Added support for default EnvoyProxy settings on EnvoyGatewaySpec that can be overridden by GatewayClass or Gateway-level EnvoyProxy configurations. A new MergeType field allows choosing between Replace (default), StrategicMerge, or JSONMerge strategies for combining configurations. Added support for sending Envoy Gateway route metadata to external authorization backends via `SecurityPolicy.spec.extAuth.includeRouteMetadata`. + Added support for request header matching in `BackendTrafficPolicy` response overrides via `responseOverride[*].match.requestHeaders`. bug fixes: | Fixed local rate limit rules with identical sourceCIDR client selectors producing conflicting descriptors. diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 01dded902c..60e560bc28 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -1305,13 +1305,16 @@ _Appears in:_ CustomResponseMatch defines the configuration for matching a user response to return a custom one. +At least one of statusCodes or requestHeaders must be specified. +When multiple criteria are specified, all of them must match (logical AND) for the rule to apply. _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. | +| `requestHeaders` | _[ResponseOverrideHeaderMatch](#responseoverrideheadermatch) array_ | false | | RequestHeaders to match on. All of the specified headers must match (logical AND)
for the rule to apply. Matching is performed against request headers observed on
the response path of the stream. | #### CustomTag @@ -5428,6 +5431,21 @@ _Appears in:_ | `redirect` | _[CustomRedirect](#customredirect)_ | true | | Redirect configuration | +#### ResponseOverrideHeaderMatch + + + +ResponseOverrideHeaderMatch defines the configuration for matching an HTTP header. + +_Appears in:_ +- [CustomResponseMatch](#customresponsematch) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `name` | _string_ | true | | Name of the HTTP header to match against. The header name is case-insensitive. | +| `value` | _[StringMatch](#stringmatch)_ | true | | Value specifies how to match against the value of the header. | + + #### ResponseValueType _Underlying type:_ _string_ @@ -5874,6 +5892,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 5bb761b90e..15a5b9064b 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -2567,6 +2567,73 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { "only ConfigMap is supported for ValueRe", }, }, + { + desc: "response override match with neither statusCodes nor requestHeaders", + 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{ + Type: new(egv1a1.ResponseValueTypeInline), + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{ + "at least one of statusCodes or requestHeaders must be specified", + }, + }, + { + desc: "response override match with only requestHeaders is valid", + 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{ + RequestHeaders: []egv1a1.ResponseOverrideHeaderMatch{ + { + Name: "Accept", + Value: egv1a1.StringMatch{ + Value: "application/json", + }, + }, + }, + }, + Response: &egv1a1.CustomResponse{ + Body: &egv1a1.CustomResponseBody{ + Type: new(egv1a1.ResponseValueTypeInline), + Inline: new("foo"), + }, + }, + }, + }, + } + }, + wantErrors: []string{}, + }, { desc: "valid Global rate limit rules with request and response hit addends", mutate: func(btp *egv1a1.BackendTrafficPolicy) { diff --git a/test/e2e/testdata/response-override.yaml b/test/e2e/testdata/response-override.yaml index 2bcebcf76a..5157520e3d 100644 --- a/test/e2e/testdata/response-override.yaml +++ b/test/e2e/testdata/response-override.yaml @@ -103,3 +103,26 @@ spec: path: type: ReplaceFullPath replaceFullPath: /status/301 + - match: + statusCodes: + - type: Value + value: 418 + requestHeaders: + - name: Accept + value: + type: Exact + value: application/json + response: + contentType: application/json + body: + type: Inline + inline: '{"error":"I am a teapot"}' + - match: + statusCodes: + - type: Value + value: 418 + response: + contentType: text/html + body: + type: Inline + inline: "

I'm a teapot

" diff --git a/test/e2e/tests/direct-response.go b/test/e2e/tests/direct-response.go index 30f10db58b..e2dd042691 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, "/inline", "text/plain", "GET Oops! Your request is not found.", 200, map[string]string{ + verifyCustomResponse(t, nil, &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", @@ -42,13 +42,13 @@ var DirectResponseTest = suite.ConformanceTest{ }) // 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{ + verifyCustomResponse(t, nil, &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", }) // Test status-only response with add and set headers - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/401", "", ``, 401, map[string]string{ + verifyCustomResponse(t, nil, &suite.TimeoutConfig, gwAddr, "/401", "", ``, 401, 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..54e3be800f 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, "/status/404", "text/plain", "404 Oops! Your request is not found.", 404, map[string]string{ + verifyCustomResponse(t, nil, &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", @@ -58,22 +58,31 @@ var ResponseOverrideTest = suite.ConformanceTest{ }) // 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{ + verifyCustomResponse(t, nil, &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", }) // Test 403 response override with add and set headers (status override to 404) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/403", "", "", 404, map[string]string{ + verifyCustomResponse(t, nil, &suite.TimeoutConfig, gwAddr, "/status/403", "", "", 404, map[string]string{ "X-Add-Header": "added-403", "X-Set-Header": "set-403", }) - verifyCustomResponse(t, &suite.TimeoutConfig, gwAddr, "/status/401", "", "", 301) + verifyCustomResponse(t, nil, &suite.TimeoutConfig, gwAddr, "/status/401", "", "", 301) + + // Test 418 response override with request header match. + // Clients sending Accept: application/json get a JSON body; others get HTML. + verifyCustomResponse(t, map[string]string{"Accept": "application/json"}, + &suite.TimeoutConfig, gwAddr, "/status/418", + "application/json", `{"error":"I am a teapot"}`, 418) + verifyCustomResponse(t, map[string]string{"Accept": "text/html"}, + &suite.TimeoutConfig, gwAddr, "/status/418", + "text/html", "

I'm a teapot

", 418) }) }, } -func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwAddr, +func verifyCustomResponse(t *testing.T, withHeaders map[string]string, timeoutConfig *config.TimeoutConfig, gwAddr, path, expectedContentType, expectedBody string, expectedStatusCode int, expectedHeaders ...map[string]string, ) { if timeoutConfig == nil { @@ -87,7 +96,16 @@ func verifyCustomResponse(t *testing.T, timeoutConfig *config.TimeoutConfig, gwA } 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 build request: %v", err) + return false + } + for k, v := range withHeaders { + req.Header.Set(k, v) + } + + rsp, err := http.DefaultClient.Do(req) if err != nil { tlog.Logf(t, "failed to get response: %v", err) return false diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 78b32eb49d..b7429631f4 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -24665,6 +24665,51 @@ spec: match: description: Match configuration. properties: + requestHeaders: + description: |- + RequestHeaders to match on. All of the specified headers must match (logical AND) + for the rule to apply. Matching is performed against request headers observed on + the response path of the stream. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching an HTTP header. + properties: + name: + description: Name of the HTTP header to match against. + The header name is case-insensitive. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value specifies how to match against + the value of the header. + 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. @@ -24720,9 +24765,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or requestHeaders must + be specified + rule: has(self.statusCodes) || has(self.requestHeaders) 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 c4f6463b73..e97c04f4a3 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -2638,6 +2638,51 @@ spec: match: description: Match configuration. properties: + requestHeaders: + description: |- + RequestHeaders to match on. All of the specified headers must match (logical AND) + for the rule to apply. Matching is performed against request headers observed on + the response path of the stream. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching an HTTP header. + properties: + name: + description: Name of the HTTP header to match against. + The header name is case-insensitive. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value specifies how to match against + the value of the header. + 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. @@ -2693,9 +2738,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or requestHeaders must + be specified + rule: has(self.statusCodes) || has(self.requestHeaders) 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 0561ffb5a1..623546c573 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -2638,6 +2638,51 @@ spec: match: description: Match configuration. properties: + requestHeaders: + description: |- + RequestHeaders to match on. All of the specified headers must match (logical AND) + for the rule to apply. Matching is performed against request headers observed on + the response path of the stream. + items: + description: ResponseOverrideHeaderMatch defines the configuration + for matching an HTTP header. + properties: + name: + description: Name of the HTTP header to match against. + The header name is case-insensitive. + maxLength: 256 + minLength: 1 + type: string + value: + description: Value specifies how to match against + the value of the header. + 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. @@ -2693,9 +2738,11 @@ spec: maxItems: 50 minItems: 1 type: array - required: - - statusCodes type: object + x-kubernetes-validations: + - message: at least one of statusCodes or requestHeaders must + be specified + rule: has(self.statusCodes) || has(self.requestHeaders) redirect: description: Redirect configuration properties: