diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 648aebaeb5c..e51947411d8 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -237,7 +237,7 @@ func (t *Translator) processHTTPRouteRules(httpRoute *HTTPRouteContext, parentRe // If the route has no valid backends then just use a direct response and don't fuss with weighted responses for _, ruleRoute := range ruleRoutes { noValidBackends := ruleRoute.Destination == nil || ruleRoute.Destination.ToBackendWeights().Valid == 0 - if noValidBackends && ruleRoute.Redirect == nil { + if ruleRoute.DirectResponse == nil && noValidBackends && ruleRoute.Redirect == nil { ruleRoute.DirectResponse = &ir.CustomResponse{ StatusCode: ptr.To(uint32(500)), } diff --git a/internal/gatewayapi/testdata/httproute-with-direct-response.in.yaml b/internal/gatewayapi/testdata/httproute-with-direct-response.in.yaml new file mode 100644 index 00000000000..bd9a316227e --- /dev/null +++ b/internal/gatewayapi/testdata/httproute-with-direct-response.in.yaml @@ -0,0 +1,119 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + namespace: envoy-gateway + name: gateway-1 + spec: + gatewayClassName: envoy-gateway-class + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: "*.envoyproxy.io" + allowedRoutes: + namespaces: + from: All +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: direct-response + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - matches: + - path: + type: PathPrefix + value: /inline + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-inline + - matches: + - path: + type: PathPrefix + value: /value-ref + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-value-ref +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + name: direct-response-with-errors + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - matches: + - path: + type: PathPrefix + value: /value-ref-not-found + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-value-ref-not-found +configMaps: +- apiVersion: v1 + kind: ConfigMap + metadata: + name: value-ref-response + namespace: default + data: + response.body: '{"error": "Internal Server Error"}' +httpFilters: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: direct-response-inline + namespace: default + spec: + directResponse: + contentType: text/plain + body: + type: Inline + inline: "OK" +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: direct-response-value-ref-not-exit + namespace: default + spec: + directResponse: + contentType: application/json + statusCode: 502 + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: value-ref-does-not-exist +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: HTTPRouteFilter + metadata: + name: direct-response-value-ref + namespace: default + spec: + directResponse: + contentType: application/json + statusCode: 502 + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: value-ref-response diff --git a/internal/gatewayapi/testdata/httproute-with-direct-response.out.yaml b/internal/gatewayapi/testdata/httproute-with-direct-response.out.yaml new file mode 100644 index 00000000000..29b6b051366 --- /dev/null +++ b/internal/gatewayapi/testdata/httproute-with-direct-response.out.yaml @@ -0,0 +1,208 @@ +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: envoy-gateway + spec: + gatewayClassName: envoy-gateway-class + listeners: + - allowedRoutes: + namespaces: + from: All + hostname: '*.envoyproxy.io' + name: http + port: 80 + protocol: HTTP + status: + listeners: + - attachedRoutes: 2 + conditions: + - lastTransitionTime: null + message: Sending translated listener configuration to the data plane + reason: Programmed + status: "True" + type: Programmed + - lastTransitionTime: null + message: Listener has been successfully translated + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Listener references have been resolved + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + name: http + supportedKinds: + - group: gateway.networking.k8s.io + kind: HTTPRoute + - group: gateway.networking.k8s.io + kind: GRPCRoute +httpRoutes: +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: direct-response + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-inline + type: ExtensionRef + matches: + - path: + type: PathPrefix + value: /inline + - filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-value-ref + type: ExtensionRef + matches: + - path: + type: PathPrefix + value: /value-ref + status: + parents: + - conditions: + - lastTransitionTime: null + message: Route is accepted + reason: Accepted + status: "True" + type: Accepted + - lastTransitionTime: null + message: Resolved all the Object references for the Route + reason: ResolvedRefs + status: "True" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +- apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + creationTimestamp: null + name: direct-response-with-errors + namespace: default + spec: + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + sectionName: http + rules: + - filters: + - extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-value-ref-not-found + type: ExtensionRef + matches: + - path: + type: PathPrefix + value: /value-ref-not-found + status: + parents: + - conditions: + - lastTransitionTime: null + message: 'Unable to translate HTTPRouteFilter: default/direct-response-value-ref-not-found' + reason: UnsupportedValue + status: "False" + type: Accepted + - lastTransitionTime: null + message: 'Unable to translate HTTPRouteFilter: default/direct-response-value-ref-not-found' + reason: BackendNotFound + status: "False" + type: ResolvedRefs + controllerName: gateway.envoyproxy.io/gatewayclass-controller + parentRef: + name: gateway-1 + namespace: envoy-gateway + sectionName: http +infraIR: + envoy-gateway/gateway-1: + proxy: + listeners: + - address: null + name: envoy-gateway/gateway-1/http + ports: + - containerPort: 10080 + name: http-80 + protocol: HTTP + servicePort: 80 + metadata: + labels: + gateway.envoyproxy.io/owning-gateway-name: gateway-1 + gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway + name: envoy-gateway/gateway-1 +xdsIR: + envoy-gateway/gateway-1: + accessLog: + text: + - path: /dev/stdout + http: + - address: 0.0.0.0 + hostnames: + - '*.envoyproxy.io' + isHTTP2: false + metadata: + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http + path: + escapedSlashesAction: UnescapeAndRedirect + mergeSlashes: true + port: 10080 + routes: + - addResponseHeaders: + - append: false + name: Content-Type + value: + - application/json + directResponse: + body: '{"error": "Internal Server Error"}' + statusCode: 502 + hostname: '*.envoyproxy.io' + isHTTP2: false + metadata: + kind: HTTPRoute + name: direct-response + namespace: default + name: httproute/default/direct-response/rule/1/match/0/*_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /value-ref + - addResponseHeaders: + - append: false + name: Content-Type + value: + - text/plain + directResponse: + body: OK + statusCode: 200 + hostname: '*.envoyproxy.io' + isHTTP2: false + metadata: + kind: HTTPRoute + name: direct-response + namespace: default + name: httproute/default/direct-response/rule/0/match/0/*_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: /inline diff --git a/site/content/en/latest/tasks/traffic/direct-response.md b/site/content/en/latest/tasks/traffic/direct-response.md new file mode 100644 index 00000000000..4b9aaa5551e --- /dev/null +++ b/site/content/en/latest/tasks/traffic/direct-response.md @@ -0,0 +1,284 @@ +--- +title: "Direct Response" +--- + +Direct responses are valuable in cases where you want the gateway itself +to handle certain requests without forwarding them to backend services. +This task shows you how to configure them. + +## Installation + +Follow the steps from the [Quickstart](../../quickstart) to install Envoy Gateway and the example manifest. +Before proceeding, you should be able to query the example backend using HTTP. + +## Testing Direct Response + +{{< tabpane text=true >}} +{{% tab header="Apply from stdin" %}} + +```shell +cat <}} + +```shell +curl --header "Host: timeout.example.com" http://${GATEWAY_HOST}/?delay=3s -I +``` + +```console +HTTP/1.1 200 OK +content-type: application/json +x-content-type-options: nosniff +date: Mon, 04 Mar 2024 02:34:21 GMT +content-length: 480 +``` + +Then we set the request timeout to 2 seconds. In this case, Envoy Gateway will respond with a timeout. + +{{< tabpane text=true >}} +{{% tab header="Apply from stdin" %}} + +```shell +cat <}} + +```shell +curl --verbose --header "Host: www.example.com" http://$GATEWAY_HOST/inline +``` + +```console +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 +> GET /inline HTTP/1.1 +> Host: www.example.com +> User-Agent: curl/8.4.0 +> Accept: */* +> +< HTTP/1.1 503 Service Unavailable +< content-type: text/plain +< content-length: 32 +< date: Sat, 02 Nov 2024 00:35:48 GMT +< +* Connection #0 to host 127.0.0.1 left intact +Oops! Your request is not found. +``` + +```shell +curl --verbose --header "Host: www.example.com" http://$GATEWAY_HOST/value-ref +``` + +```console +* Trying 127.0.0.1:80... +* Connected to 127.0.0.1 (127.0.0.1) port 80 +> GET /value-ref HTTP/1.1 +> Host: www.example.com +> User-Agent: curl/8.4.0 +> Accept: */* +> +< HTTP/1.1 500 Internal Server Error +< content-type: application/json +< content-length: 34 +< date: Sat, 02 Nov 2024 00:35:55 GMT +< +* Connection #0 to host 127.0.0.1 left intact +{"error": "Internal Server Error"} +``` diff --git a/test/e2e/testdata/direct-response.yaml b/test/e2e/testdata/direct-response.yaml new file mode 100644 index 00000000000..a1d2d81e8bb --- /dev/null +++ b/test/e2e/testdata/direct-response.yaml @@ -0,0 +1,64 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: direct-response + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /inline + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-inline + - matches: + - path: + type: PathPrefix + value: /value-ref + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: direct-response-value-ref +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: value-ref-response + namespace: gateway-conformance-infra +data: + response.body: '{"error": "Internal Server Error"}' +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: HTTPRouteFilter +metadata: + name: direct-response-inline + namespace: gateway-conformance-infra +spec: + directResponse: + contentType: text/plain + body: + type: Inline + inline: "Oops! Your request is not found." +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: HTTPRouteFilter +metadata: + name: direct-response-value-ref + namespace: gateway-conformance-infra +spec: + directResponse: + contentType: application/json + body: + type: ValueRef + valueRef: + group: "" + kind: ConfigMap + name: value-ref-response diff --git a/test/e2e/tests/direct-response.go b/test/e2e/tests/direct-response.go new file mode 100644 index 00000000000..12c667fdd30 --- /dev/null +++ b/test/e2e/tests/direct-response.go @@ -0,0 +1,38 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e + +package tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +func init() { + ConformanceTests = append(ConformanceTests, DirectResponseTest) +} + +var DirectResponseTest = suite.ConformanceTest{ + ShortName: "DirectResponse", + Description: "Direct", + Manifests: []string{"testdata/direct-response.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("direct response", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "direct-response", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/inline", "text/plain", "Oops! Your request is not found.") + verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/value-ref", "application/json", `{"error": "Internal Server Error"}`) + }) + }, +} diff --git a/test/e2e/tests/response-override.go b/test/e2e/tests/response-override.go index b21db88e242..c7c12bd2c10 100644 --- a/test/e2e/tests/response-override.go +++ b/test/e2e/tests/response-override.go @@ -8,18 +8,20 @@ package tests import ( - "fmt" "io" "net/http" "net/url" "testing" + "time" "k8s.io/apimachinery/pkg/types" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "sigs.k8s.io/gateway-api/conformance/utils/config" httputils "sigs.k8s.io/gateway-api/conformance/utils/http" "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/conformance/utils/tlog" "github.com/envoyproxy/gateway/internal/gatewayapi" "github.com/envoyproxy/gateway/internal/gatewayapi/resource" @@ -47,37 +49,47 @@ var ResponseOverrideTest = suite.ConformanceTest{ Name: gwapiv1.ObjectName(gwNN.Name), } BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "response-override", Namespace: ns}, suite.ControllerName, ancestorRef) - verifyResponseOverride(t, gwAddr, 404, "text/plain", "Oops! Your request is not found.") - verifyResponseOverride(t, gwAddr, 500, "application/json", `{"error": "Internal Server Error"}`) + verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/status/404", "text/plain", "Oops! Your request is not found.") + verifyCustomResponse(t, suite.TimeoutConfig, gwAddr, "/status/500", "application/json", `{"error": "Internal Server Error"}`) }) }, } -func verifyResponseOverride(t *testing.T, gwAddr string, statusCode int, expectedContentType string, expectedBody string) { +func verifyCustomResponse(t *testing.T, timeoutConfig config.TimeoutConfig, gwAddr, path, expectedContentType, expectedBody string) { reqURL := url.URL{ Scheme: "http", Host: httputils.CalculateHost(t, gwAddr, "http"), - Path: fmt.Sprintf("/status/%d", statusCode), + Path: path, } - rsp, err := http.Get(reqURL.String()) - if err != nil { - t.Fatalf("failed to get response: %v", err) - } + httputils.AwaitConvergence(t, timeoutConfig.RequiredConsecutiveSuccesses, timeoutConfig.MaxTimeToConsistency, func(elapsed time.Duration) bool { + rsp, err := http.Get(reqURL.String()) + if err != nil { + tlog.Logf(t, "failed to get response: %v", err) + return false + } - // Verify that the response body is overridden - defer rsp.Body.Close() - body, err := io.ReadAll(rsp.Body) - if err != nil { - t.Fatalf("failed to read response body: %v", err) - } - if string(body) != expectedBody { - t.Errorf("expected response body to be %s but got %s", expectedBody, string(body)) - } + // Verify that the response body is 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 string(body) != expectedBody { + tlog.Logf(t, "expected response body to be %s but got %s", expectedBody, string(body)) + return false + } - // Verify that the content type is overridden - contentType := rsp.Header.Get("Content-Type") - if contentType != expectedContentType { - t.Errorf("expected content type to be %s but got %s", expectedContentType, contentType) - } + // 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) + return false + } + + return true + }) + + tlog.Logf(t, "Request passed") }