From 022f70bce4d85c08ff0c6aee4af5376d38f92ca5 Mon Sep 17 00:00:00 2001 From: Huabing Zhao Date: Fri, 3 Jan 2025 09:04:03 +0000 Subject: [PATCH] implement compression Signed-off-by: Huabing Zhao --- api/v1alpha1/backendtrafficpolicy_types.go | 1 - api/v1alpha1/envoyproxy_types.go | 3 + internal/gatewayapi/backendtrafficpolicy.go | 17 + .../backendtrafficpolicy-compression.in.yaml | 48 +++ .../backendtrafficpolicy-compression.out.yaml | 169 ++++++++++ internal/ir/xds.go | 9 + internal/ir/zz_generated.deepcopy.go | 20 ++ internal/xds/translator/compressor.go | 157 +++++++++ .../testdata/in/xds-ir/compression.yaml | 39 +++ .../out/xds-ir/compression.clusters.yaml | 17 + .../out/xds-ir/compression.endpoints.yaml | 12 + .../out/xds-ir/compression.listeners.yaml | 42 +++ .../out/xds-ir/compression.routes.yaml | 33 ++ site/content/en/latest/api/extension_types.md | 2 + site/content/zh/latest/api/extension_types.md | 2 + test/e2e/testdata/compression.yaml | 47 +++ test/e2e/tests/compression.go | 97 ++++++ test/e2e/tests/roundtripper.go | 309 ++++++++++++++++++ 18 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 internal/gatewayapi/testdata/backendtrafficpolicy-compression.in.yaml create mode 100644 internal/gatewayapi/testdata/backendtrafficpolicy-compression.out.yaml create mode 100644 internal/xds/translator/compressor.go create mode 100644 internal/xds/translator/testdata/in/xds-ir/compression.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/compression.clusters.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/compression.endpoints.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/compression.listeners.yaml create mode 100644 internal/xds/translator/testdata/out/xds-ir/compression.routes.yaml create mode 100644 test/e2e/testdata/compression.yaml create mode 100644 test/e2e/tests/compression.go create mode 100644 test/e2e/tests/roundtripper.go diff --git a/api/v1alpha1/backendtrafficpolicy_types.go b/api/v1alpha1/backendtrafficpolicy_types.go index 4183c12830f9..79b0e5a540a7 100644 --- a/api/v1alpha1/backendtrafficpolicy_types.go +++ b/api/v1alpha1/backendtrafficpolicy_types.go @@ -67,7 +67,6 @@ type BackendTrafficPolicySpec struct { // The compression config for the http streams. // // +optional - // +notImplementedHide Compression []*Compression `json:"compression,omitempty"` // ResponseOverride defines the configuration to override specific responses with a custom one. diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go index cbf2c9226d0b..e0c431858d49 100644 --- a/api/v1alpha1/envoyproxy_types.go +++ b/api/v1alpha1/envoyproxy_types.go @@ -233,6 +233,9 @@ const ( // EnvoyFilterCustomResponse defines the Envoy HTTP custom response filter. EnvoyFilterCustomResponse EnvoyFilter = "envoy.filters.http.custom_response" + // EnvoyFilterCompressor defines the Envoy HTTP compressor filter. + EnvoyFilterCompressor EnvoyFilter = "envoy.filters.http.compressor" + // EnvoyFilterRouter defines the Envoy HTTP router filter. EnvoyFilterRouter EnvoyFilter = "envoy.filters.http.router" ) diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index 0934629428bd..560cb8bb7f57 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -305,6 +305,7 @@ func (t *Translator) translateBackendTrafficPolicyForRoute( ds *ir.DNS h2 *ir.HTTP2Settings ro *ir.ResponseOverride + cp *ir.Compression err, errs error ) @@ -354,6 +355,7 @@ func (t *Translator) translateBackendTrafficPolicyForRoute( err = perr.WithMessage(err, "ResponseOverride") errs = errors.Join(errs, err) } + cp = buildCompression(policy.Spec.Compression) ds = translateDNS(policy.Spec.ClusterSettings) @@ -417,6 +419,7 @@ func (t *Translator) translateBackendTrafficPolicyForRoute( DNS: ds, Timeout: to, ResponseOverride: ro, + Compression: cp, } // Update the Host field in HealthCheck, now that we have access to the Route Hostname. @@ -453,6 +456,7 @@ func (t *Translator) translateBackendTrafficPolicyForGateway( ds *ir.DNS h2 *ir.HTTP2Settings ro *ir.ResponseOverride + cp *ir.Compression err, errs error ) @@ -495,6 +499,7 @@ func (t *Translator) translateBackendTrafficPolicyForGateway( err = perr.WithMessage(err, "ResponseOverride") errs = errors.Join(errs, err) } + cp = buildCompression(policy.Spec.Compression) ds = translateDNS(policy.Spec.ClusterSettings) @@ -579,6 +584,7 @@ func (t *Translator) translateBackendTrafficPolicyForGateway( HTTP2: h2, DNS: ds, ResponseOverride: ro, + Compression: cp, } // Update the Host field in HealthCheck, now that we have access to the Route Hostname. @@ -930,3 +936,14 @@ func defaultResponseOverrideRuleName(policy *egv1a1.BackendTrafficPolicy, index irConfigName(policy), strconv.Itoa(index)) } + +func buildCompression(compression []*egv1a1.Compression) *ir.Compression { + if len(compression) == 0 { + return nil + } + + // Only Gzip is supported for now, so we don't need to do anything special here + return &ir.Compression{ + Type: "GZip", + } +} diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-compression.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-compression.in.yaml new file mode 100644 index 000000000000..5dd8e1adf211 --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-compression.in.yaml @@ -0,0 +1,48 @@ +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 + allowedRoutes: + namespaces: + from: All +httpRoutes: + - apiVersion: gateway.networking.k8s.io/v1 + kind: HTTPRoute + metadata: + namespace: default + name: httproute-1 + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - namespace: envoy-gateway + 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 + spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-1 + compression: + - type: Gzip diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-compression.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-compression.out.yaml new file mode 100644 index 000000000000..caeff8722a78 --- /dev/null +++ b/internal/gatewayapi/testdata/backendtrafficpolicy-compression.out.yaml @@ -0,0 +1,169 @@ +backendTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + creationTimestamp: null + name: policy-for-route + namespace: default + spec: + compression: + - type: Gzip + 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: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller +gateways: +- apiVersion: gateway.networking.k8s.io/v1 + kind: Gateway + metadata: + creationTimestamp: null + name: gateway-1 + namespace: envoy-gateway + 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: + creationTimestamp: null + name: httproute-1 + namespace: default + spec: + hostnames: + - gateway.envoyproxy.io + parentRefs: + - name: gateway-1 + namespace: envoy-gateway + 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: 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: + - '*' + 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: + - destination: + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / + traffic: + compression: + type: GZip diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 4db7b8445aaa..8748d6b72091 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -731,6 +731,13 @@ type HeaderBasedSessionPersistence struct { Name string `json:"name"` } +// Compression holds the configuration for HTTP compression. +// Currently, only the default compressor(gzip) is supported. +// +k8s:deepcopy-gen=true +type Compression struct { + Type string `json:"type" yaml:"type"` +} + // TrafficFeatures holds the information associated with the Backend Traffic Policy. // +k8s:deepcopy-gen=true type TrafficFeatures struct { @@ -762,6 +769,8 @@ type TrafficFeatures struct { DNS *DNS `json:"dns,omitempty" yaml:"dns,omitempty"` // ResponseOverride defines the schema for overriding the response. ResponseOverride *ResponseOverride `json:"responseOverride,omitempty" yaml:"responseOverride,omitempty"` + // Compression settings for HTTP Response + Compression *Compression `json:"compression,omitempty" yaml:"compression,omitempty"` } func (b *TrafficFeatures) Validate() error { diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index 0c734dbec70d..7a5abdca402d 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -506,6 +506,21 @@ func (in *ClientTimeout) DeepCopy() *ClientTimeout { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Compression) DeepCopyInto(out *Compression) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Compression. +func (in *Compression) DeepCopy() *Compression { + if in == nil { + return nil + } + out := new(Compression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ConnectionLimit) DeepCopyInto(out *ConnectionLimit) { *out = *in @@ -3340,6 +3355,11 @@ func (in *TrafficFeatures) DeepCopyInto(out *TrafficFeatures) { *out = new(ResponseOverride) (*in).DeepCopyInto(*out) } + if in.Compression != nil { + in, out := &in.Compression, &out.Compression + *out = new(Compression) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TrafficFeatures. diff --git a/internal/xds/translator/compressor.go b/internal/xds/translator/compressor.go new file mode 100644 index 000000000000..b9a500170df8 --- /dev/null +++ b/internal/xds/translator/compressor.go @@ -0,0 +1,157 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "errors" + "fmt" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + gzipv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/compression/gzip/compressor/v3" + compressorv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/compressor/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + "google.golang.org/protobuf/types/known/anypb" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/utils/protocov" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +func init() { + registerHTTPFilter(&compressor{}) +} + +type compressor struct{} + +var _ httpFilter = &compressor{} + +// patchHCM builds and appends the compressor Filter to the HTTP Connection Manager +// if applicable, and it does not already exist. +func (*compressor) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { + if mgr == nil { + return errors.New("hcm is nil") + } + if irListener == nil { + return errors.New("ir listener is nil") + } + if hcmContainsFilter(mgr, egv1a1.EnvoyFilterCompressor.String()) { + return nil + } + + var ( + irCompression *ir.Compression + filter *hcmv3.HttpFilter + err error + ) + + for _, route := range irListener.Routes { + if route.Traffic != nil && route.Traffic.Compression != nil { + irCompression = route.Traffic.Compression + } + } + if irCompression == nil { + return nil + } + + // The HCM-level filter config doesn't matter since it is overridden at the route level. + if filter, err = buildHCMCompressorFilter(); err != nil { + return err + } + mgr.HttpFilters = append(mgr.HttpFilters, filter) + return err +} + +// buildHCMCompressorFilter returns a Compressor HTTP filter from the provided IR HTTPRoute. +func buildHCMCompressorFilter() (*hcmv3.HttpFilter, error) { + var ( + compressorProto *compressorv3.Compressor + gzipAny *anypb.Any + compressorAny *anypb.Any + err error + ) + + if gzipAny, err = protocov.ToAnyWithValidation(&gzipv3.Gzip{}); err != nil { + return nil, err + } + + compressorProto = &compressorv3.Compressor{ + CompressorLibrary: &corev3.TypedExtensionConfig{ + Name: "envoy.compressor.gzip", + TypedConfig: gzipAny, + }, + } + + if compressorAny, err = protocov.ToAnyWithValidation(compressorProto); err != nil { + return nil, err + } + + return &hcmv3.HttpFilter{ + Name: egv1a1.EnvoyFilterCompressor.String(), + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: compressorAny, + }, + Disabled: true, + }, nil +} + +func (*compressor) patchResources(*types.ResourceVersionTable, []*ir.HTTPRoute) error { + return nil +} + +// patchRoute patches the provided route with the compressor config if applicable. +// Note: this method overwrites the HCM level filter config with the per route filter config. +func (*compressor) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if irRoute.Traffic == nil || irRoute.Traffic.Compression == nil { + return nil + } + + var ( + perFilterCfg map[string]*anypb.Any + compressorAny *anypb.Any + err error + ) + + perFilterCfg = route.GetTypedPerFilterConfig() + if _, ok := perFilterCfg[egv1a1.EnvoyFilterCompressor.String()]; ok { + // This should not happen since this is the only place where the filter + // config is added in a route. + return fmt.Errorf("route already contains filter config: %s, %+v", + egv1a1.EnvoyFilterCompressor.String(), route) + } + + // Overwrite the HCM level filter config with the per route filter config. + compressorProto := compressorPerRouteConfig(irRoute.Traffic.Compression) + + if compressorAny, err = protocov.ToAnyWithValidation(compressorProto); err != nil { + return err + } + + if perFilterCfg == nil { + route.TypedPerFilterConfig = make(map[string]*anypb.Any) + } + route.TypedPerFilterConfig[egv1a1.EnvoyFilterCompressor.String()] = compressorAny + + return nil +} + +func compressorPerRouteConfig(_ *ir.Compression) *compressorv3.CompressorPerRoute { + // Enable compression on this route if compression is configured. + return &compressorv3.CompressorPerRoute{ + Override: &compressorv3.CompressorPerRoute_Overrides{ + Overrides: &compressorv3.CompressorOverrides{ + ResponseDirectionConfig: &compressorv3.ResponseDirectionOverrides{}, + }, + }, + } +} diff --git a/internal/xds/translator/testdata/in/xds-ir/compression.yaml b/internal/xds/translator/testdata/in/xds-ir/compression.yaml new file mode 100644 index 000000000000..5d98feb3267d --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/compression.yaml @@ -0,0 +1,39 @@ +http: +- address: 0.0.0.0 + hostnames: + - '*' + 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: + - destination: + name: httproute/default/httproute-1/rule/0 + settings: + - addressType: IP + endpoints: + - host: 7.7.7.7 + port: 8080 + protocol: HTTP + weight: 1 + hostname: gateway.envoyproxy.io + isHTTP2: false + metadata: + kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + pathMatch: + distinct: false + name: "" + prefix: / + traffic: + compression: + type: GZip diff --git a/internal/xds/translator/testdata/out/xds-ir/compression.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/compression.clusters.yaml new file mode 100644 index 000000000000..c24d059eeaaf --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/compression.clusters.yaml @@ -0,0 +1,17 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_PREFERRED + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: httproute/default/httproute-1/rule/0 + ignoreHealthOnHostRemoval: true + lbPolicy: LEAST_REQUEST + name: httproute/default/httproute-1/rule/0 + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/compression.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/compression.endpoints.yaml new file mode 100644 index 000000000000..29bb6b4e444c --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/compression.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/compression.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/compression.listeners.yaml new file mode 100644 index 000000000000..f0fb7e018907 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/compression.listeners.yaml @@ -0,0 +1,42 @@ +- 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.compressor + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.compressor.v3.Compressor + compressorLibrary: + name: envoy.compressor.gzip + typedConfig: + '@type': type.googleapis.com/envoy.extensions.compression.gzip.compressor.v3.Gzip + - 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: envoy-gateway/gateway-1/http + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: envoy-gateway/gateway-1/http + name: envoy-gateway/gateway-1/http + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/compression.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/compression.routes.yaml new file mode 100644 index 000000000000..b137baf43f88 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/compression.routes.yaml @@ -0,0 +1,33 @@ +- ignorePortInHostMatching: true + name: envoy-gateway/gateway-1/http + virtualHosts: + - domains: + - gateway.envoyproxy.io + metadata: + filterMetadata: + envoy-gateway: + resources: + - kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + name: envoy-gateway/gateway-1/http/gateway_envoyproxy_io + routes: + - match: + prefix: / + metadata: + filterMetadata: + envoy-gateway: + resources: + - kind: HTTPRoute + name: httproute-1 + namespace: default + name: httproute/default/httproute-1/rule/0/match/0/gateway_envoyproxy_io + route: + cluster: httproute/default/httproute-1/rule/0 + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.compressor: + '@type': type.googleapis.com/envoy.extensions.filters.http.compressor.v3.CompressorPerRoute + overrides: {} diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 5119d756646c..a14154c29f13 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -451,6 +451,7 @@ _Appears in:_ | `rateLimit` | _[RateLimitSpec](#ratelimitspec)_ | false | RateLimit allows the user to limit the number of incoming requests
to a predefined value based on attributes within the traffic flow. | | `faultInjection` | _[FaultInjection](#faultinjection)_ | false | FaultInjection defines the fault injection policy to be applied. This configuration can be used to
inject delays and abort requests to mimic failure scenarios such as service failures and overloads | | `useClientProtocol` | _boolean_ | false | UseClientProtocol configures Envoy to prefer sending requests to backends using
the same HTTP protocol that the incoming request used. Defaults to false, which means
that Envoy will use the protocol indicated by the attached BackendRef. | +| `compression` | _[Compression](#compression) array_ | false | The compression config for the http streams. | | `responseOverride` | _[ResponseOverride](#responseoverride) array_ | false | ResponseOverride defines the configuration to override specific responses with a custom one.
If multiple configurations are specified, the first one to match wins. | @@ -1008,6 +1009,7 @@ _Appears in:_ | `envoy.filters.http.local_ratelimit` | EnvoyFilterLocalRateLimit defines the Envoy HTTP local rate limit filter.
| | `envoy.filters.http.ratelimit` | EnvoyFilterRateLimit defines the Envoy HTTP rate limit filter.
| | `envoy.filters.http.custom_response` | EnvoyFilterCustomResponse defines the Envoy HTTP custom response filter.
| +| `envoy.filters.http.compressor` | EnvoyFilterCompressor defines the Envoy HTTP compressor filter.
| | `envoy.filters.http.router` | EnvoyFilterRouter defines the Envoy HTTP router filter.
| diff --git a/site/content/zh/latest/api/extension_types.md b/site/content/zh/latest/api/extension_types.md index 5119d756646c..a14154c29f13 100644 --- a/site/content/zh/latest/api/extension_types.md +++ b/site/content/zh/latest/api/extension_types.md @@ -451,6 +451,7 @@ _Appears in:_ | `rateLimit` | _[RateLimitSpec](#ratelimitspec)_ | false | RateLimit allows the user to limit the number of incoming requests
to a predefined value based on attributes within the traffic flow. | | `faultInjection` | _[FaultInjection](#faultinjection)_ | false | FaultInjection defines the fault injection policy to be applied. This configuration can be used to
inject delays and abort requests to mimic failure scenarios such as service failures and overloads | | `useClientProtocol` | _boolean_ | false | UseClientProtocol configures Envoy to prefer sending requests to backends using
the same HTTP protocol that the incoming request used. Defaults to false, which means
that Envoy will use the protocol indicated by the attached BackendRef. | +| `compression` | _[Compression](#compression) array_ | false | The compression config for the http streams. | | `responseOverride` | _[ResponseOverride](#responseoverride) array_ | false | ResponseOverride defines the configuration to override specific responses with a custom one.
If multiple configurations are specified, the first one to match wins. | @@ -1008,6 +1009,7 @@ _Appears in:_ | `envoy.filters.http.local_ratelimit` | EnvoyFilterLocalRateLimit defines the Envoy HTTP local rate limit filter.
| | `envoy.filters.http.ratelimit` | EnvoyFilterRateLimit defines the Envoy HTTP rate limit filter.
| | `envoy.filters.http.custom_response` | EnvoyFilterCustomResponse defines the Envoy HTTP custom response filter.
| +| `envoy.filters.http.compressor` | EnvoyFilterCompressor defines the Envoy HTTP compressor filter.
| | `envoy.filters.http.router` | EnvoyFilterRouter defines the Envoy HTTP router filter.
| diff --git a/test/e2e/testdata/compression.yaml b/test/e2e/testdata/compression.yaml new file mode 100644 index 000000000000..b5c077768ebf --- /dev/null +++ b/test/e2e/testdata/compression.yaml @@ -0,0 +1,47 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: compression + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /compression + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: no-compression + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /no-compression + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: compression + namespace: gateway-conformance-infra +spec: + targetRef: + group: gateway.networking.k8s.io + kind: HTTPRoute + name: compression + compression: + - type: Gzip diff --git a/test/e2e/tests/compression.go b/test/e2e/tests/compression.go new file mode 100644 index 000000000000..778e40a18db3 --- /dev/null +++ b/test/e2e/tests/compression.go @@ -0,0 +1,97 @@ +// 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" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + + "github.com/envoyproxy/gateway/internal/gatewayapi" + "github.com/envoyproxy/gateway/internal/gatewayapi/resource" +) + +func init() { + ConformanceTests = append(ConformanceTests, CompressionTest) +} + +var CompressionTest = suite.ConformanceTest{ + ShortName: "Compression", + Description: "Test response compression on HTTPRoute", + Manifests: []string{"testdata/compression.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("HTTPRoute with compression", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "compression", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + ancestorRef := gwapiv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + } + BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "compression", Namespace: ns}, suite.ControllerName, ancestorRef) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/compression", + Headers: map[string]string{ + "Accept-encoding": "gzip", + }, + }, + Response: http.Response{ + StatusCode: 200, + Headers: map[string]string{ + "content-encoding": "gzip", + }, + }, + Namespace: ns, + } + roundTripper := &DefaultRoundTripper{Debug: suite.Debug, TimeoutConfig: suite.TimeoutConfig} + http.MakeRequestAndExpectEventuallyConsistentResponse(t, roundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }) + + t.Run("HTTPRoute without compression", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "no-compression", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + + ancestorRef := gwapiv1a2.ParentReference{ + Group: gatewayapi.GroupPtr(gwapiv1.GroupName), + Kind: gatewayapi.KindPtr(resource.KindGateway), + Namespace: gatewayapi.NamespacePtr(gwNN.Namespace), + Name: gwapiv1.ObjectName(gwNN.Name), + } + BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "compression", Namespace: ns}, suite.ControllerName, ancestorRef) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/no-compression", + Headers: map[string]string{ + "Accept-encoding": "gzip", + }, + }, + Response: http.Response{ + StatusCode: 200, + AbsentHeaders: []string{"content-encoding"}, + }, + Namespace: ns, + } + + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }) + }, +} diff --git a/test/e2e/tests/roundtripper.go b/test/e2e/tests/roundtripper.go new file mode 100644 index 000000000000..9890f5a2eb5d --- /dev/null +++ b/test/e2e/tests/roundtripper.go @@ -0,0 +1,309 @@ +// 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. + +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This file is copied from gateway-api/conformance/utils/roundtripper/roundtripper.go and modified to add the compression support. +// TODO: remove this file when the compression support is added to the roundtripper in the gateway-api repo. + +package tests + +import ( + "compress/gzip" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "testing" + + "golang.org/x/net/http2" + "sigs.k8s.io/gateway-api/conformance/utils/config" + "sigs.k8s.io/gateway-api/conformance/utils/roundtripper" + "sigs.k8s.io/gateway-api/conformance/utils/tlog" +) + +const ( + H2CPriorKnowledgeProtocol = "H2C_PRIOR_KNOWLEDGE" +) + +// RoundTripper is an interface used to make requests within conformance tests. +// This can be overridden with custom implementations whenever necessary. +type RoundTripper interface { + CaptureRoundTrip(roundtripper.Request) (*roundtripper.CapturedRequest, *roundtripper.CapturedResponse, error) +} + +// DefaultRoundTripper is the default implementation of a RoundTripper. It will +// be used if a custom implementation is not specified. +type DefaultRoundTripper struct { + Debug bool + TimeoutConfig config.TimeoutConfig + CustomDialContext func(context.Context, string, string) (net.Conn, error) +} + +func (d *DefaultRoundTripper) httpTransport(request roundtripper.Request) (http.RoundTripper, error) { + transport := &http.Transport{ + DialContext: d.CustomDialContext, + // We disable keep-alives so that we don't leak established TCP connections. + // Leaking TCP connections is bad because we could eventually hit the + // threshold of maximum number of open TCP connections to a specific + // destination. Keep-alives are not presently utilized so disabling this has + // no adverse affect. + // + // Ref. https://github.com/kubernetes-sigs/gateway-api/issues/2357 + DisableKeepAlives: true, + } + if request.Server != "" && len(request.CertPem) != 0 && len(request.KeyPem) != 0 { + tlsConfig, err := tlsClientConfig(request.Server, request.CertPem, request.KeyPem) + if err != nil { + return nil, err + } + transport.TLSClientConfig = tlsConfig + } + + return transport, nil +} + +func (d *DefaultRoundTripper) h2cPriorKnowledgeTransport(request roundtripper.Request) (http.RoundTripper, error) { + if request.Server != "" && len(request.CertPem) != 0 && len(request.KeyPem) != 0 { + return nil, errors.New("request has configured cert and key but h2 prior knowledge is not encrypted") + } + + transport := &http2.Transport{ + AllowHTTP: true, + DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }, + } + + return transport, nil +} + +// CaptureRoundTrip makes a request with the provided parameters and returns the +// captured request and response from echoserver. An error will be returned if +// there is an error running the function but not if an HTTP error status code +// is received. +func (d *DefaultRoundTripper) CaptureRoundTrip(request roundtripper.Request) (*roundtripper.CapturedRequest, *roundtripper.CapturedResponse, error) { + var transport http.RoundTripper + var err error + + switch request.Protocol { + case H2CPriorKnowledgeProtocol: + transport, err = d.h2cPriorKnowledgeTransport(request) + default: + transport, err = d.httpTransport(request) + } + + if err != nil { + return nil, nil, err + } + + return d.defaultRoundTrip(request, transport) +} + +func (d *DefaultRoundTripper) defaultRoundTrip(request roundtripper.Request, transport http.RoundTripper) (*roundtripper.CapturedRequest, *roundtripper.CapturedResponse, error) { + client := &http.Client{} + + if request.UnfollowRedirect { + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + } + + client.Transport = transport + + method := "GET" + if request.Method != "" { + method = request.Method + } + ctx, cancel := context.WithTimeout(context.Background(), d.TimeoutConfig.RequestTimeout) + defer cancel() + ctx = withT(ctx, request.T) + req, err := http.NewRequestWithContext(ctx, method, request.URL.String(), nil) + if err != nil { + return nil, nil, err + } + + if request.Host != "" { + req.Host = request.Host + } + + if request.Headers != nil { + for name, value := range request.Headers { + req.Header.Set(name, value[0]) + } + } + + if d.Debug { + var dump []byte + dump, err = httputil.DumpRequestOut(req, true) + if err != nil { + return nil, nil, err + } + + tlog.Logf(request.T, "Sending Request:\n%s\n\n", formatDump(dump, "< ")) + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + if d.Debug { + var dump []byte + dump, err = httputil.DumpResponse(resp, true) + if err != nil { + return nil, nil, err + } + + tlog.Logf(request.T, "Received Response:\n%s\n\n", formatDump(dump, "< ")) + } + + cReq := &roundtripper.CapturedRequest{} + var body []byte + if resp.Header.Get("Content-Encoding") == "gzip" { + reader, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, nil, err + } + defer reader.Close() + if body, err = io.ReadAll(reader); err != nil { + return nil, nil, err + } + } else { + if body, err = io.ReadAll(resp.Body); err != nil { + return nil, nil, err + } + } + + // we cannot assume the response is JSON + if resp.Header.Get("Content-type") == "application/json" { + err = json.Unmarshal(body, cReq) + if err != nil { + return nil, nil, fmt.Errorf("unexpected error reading response: %w", err) + } + } else { + cReq.Method = method // assume it made the right request if the service being called isn't echoing + } + + cRes := &roundtripper.CapturedResponse{ + StatusCode: resp.StatusCode, + ContentLength: resp.ContentLength, + Protocol: resp.Proto, + Headers: resp.Header, + } + + if resp.TLS != nil { + cRes.PeerCertificates = resp.TLS.PeerCertificates + } + + if IsRedirect(resp.StatusCode) { + redirectURL, err := resp.Location() + if err != nil { + return nil, nil, err + } + cRes.RedirectRequest = &roundtripper.RedirectRequest{ + Scheme: redirectURL.Scheme, + Host: redirectURL.Hostname(), + Port: redirectURL.Port(), + Path: redirectURL.Path, + } + } + + return cReq, cRes, nil +} + +func tlsClientConfig(server string, certPem []byte, keyPem []byte) (*tls.Config, error) { + // Create a certificate from the provided cert and key + cert, err := tls.X509KeyPair(certPem, keyPem) + if err != nil { + return nil, fmt.Errorf("unexpected error creating cert: %w", err) + } + + // Add the provided cert as a trusted CA + certPool := x509.NewCertPool() + if !certPool.AppendCertsFromPEM(certPem) { + return nil, fmt.Errorf("unexpected error adding trusted CA: %w", err) + } + + if server == "" { + return nil, fmt.Errorf("unexpected error, server name required for TLS") + } + + // Create the tls Config for this provided host, cert, and trusted CA + // Disable G402: TLS MinVersion too low. (gosec) + // #nosec G402 + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ServerName: server, + RootCAs: certPool, + }, nil +} + +// IsRedirect returns true if a given status code is a redirect code. +func IsRedirect(statusCode int) bool { + switch statusCode { + case http.StatusMultipleChoices, + http.StatusMovedPermanently, + http.StatusFound, + http.StatusSeeOther, + http.StatusNotModified, + http.StatusUseProxy, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect: + return true + } + return false +} + +// IsTimeoutError returns true if a given status code is a timeout error code. +func IsTimeoutError(statusCode int) bool { + switch statusCode { + case http.StatusRequestTimeout, + http.StatusGatewayTimeout: + return true + } + return false +} + +// testingTContextKey is the key for adding testing.T to the context.Context +type testingTContextKey struct{} + +// withT returns a context with the testing.T added as a value. +func withT(ctx context.Context, t *testing.T) context.Context { + return context.WithValue(ctx, testingTContextKey{}, t) +} + +// TFromContext returns the testing.T added to the context if available. +func TFromContext(ctx context.Context) (*testing.T, bool) { + v := ctx.Value(testingTContextKey{}) + if v != nil { + if t, ok := v.(*testing.T); ok { + return t, true + } + } + return nil, false +}