diff --git a/CODEOWNERS b/CODEOWNERS index c0918fe362df8..66a89e71429fb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,8 @@ extensions/filters/common/original_src @snowp @klarose /*/extensions/filters/http/dynamic_forward_proxy @mattklein123 @alyssawilk # omit_canary_hosts retry predicate /*/extensions/retry/host/omit_canary_hosts @sriduth @snowp +# HTTP caching extension +/*/extensions/filters/http/cache @toddmgreer @jmarantz # aws_iam grpc credentials /*/extensions/grpc_credentials/aws_iam @lavignes @mattklein123 /*/extensions/filters/http/common/aws @lavignes @mattklein123 diff --git a/api/BUILD b/api/BUILD index 69443c3e2df11..6ca65bdef52e7 100644 --- a/api/BUILD +++ b/api/BUILD @@ -29,6 +29,7 @@ proto_library( "//envoy/config/filter/fault/v2:pkg", "//envoy/config/filter/http/adaptive_concurrency/v2alpha:pkg", "//envoy/config/filter/http/buffer/v2:pkg", + "//envoy/config/filter/http/cache/v2:pkg", "//envoy/config/filter/http/cors/v2:pkg", "//envoy/config/filter/http/csrf/v2:pkg", "//envoy/config/filter/http/dynamic_forward_proxy/v2alpha:pkg", @@ -156,6 +157,7 @@ proto_library( "//envoy/extensions/filters/common/fault/v3:pkg", "//envoy/extensions/filters/http/adaptive_concurrency/v3:pkg", "//envoy/extensions/filters/http/buffer/v3:pkg", + "//envoy/extensions/filters/http/cache/v3:pkg", "//envoy/extensions/filters/http/cors/v3:pkg", "//envoy/extensions/filters/http/csrf/v3:pkg", "//envoy/extensions/filters/http/dynamic_forward_proxy/v3:pkg", diff --git a/api/docs/BUILD b/api/docs/BUILD index 38683df0597f7..c2f35947ed898 100644 --- a/api/docs/BUILD +++ b/api/docs/BUILD @@ -35,6 +35,7 @@ proto_library( "//envoy/config/filter/fault/v2:pkg", "//envoy/config/filter/http/adaptive_concurrency/v2alpha:pkg", "//envoy/config/filter/http/buffer/v2:pkg", + "//envoy/config/filter/http/cache/v2:pkg", "//envoy/config/filter/http/cors/v2:pkg", "//envoy/config/filter/http/csrf/v2:pkg", "//envoy/config/filter/http/dynamic_forward_proxy/v2alpha:pkg", diff --git a/api/envoy/config/filter/http/cache/v2/BUILD b/api/envoy/config/filter/http/cache/v2/BUILD new file mode 100644 index 0000000000000..87746bf482e03 --- /dev/null +++ b/api/envoy/config/filter/http/cache/v2/BUILD @@ -0,0 +1,13 @@ +# DO NOT EDIT. This file is generated by tools/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/api/v2/route:pkg", + "//envoy/type/matcher:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/config/filter/http/cache/v2/cache.proto b/api/envoy/config/filter/http/cache/v2/cache.proto new file mode 100644 index 0000000000000..59c0ffa78b86f --- /dev/null +++ b/api/envoy/config/filter/http/cache/v2/cache.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; + +package envoy.config.filter.http.cache.v2; + +import "envoy/api/v2/route/route_components.proto"; +import "envoy/type/matcher/string.proto"; + +import "google/protobuf/any.proto"; + +import "udpa/annotations/migrate.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.config.filter.http.cache.v2"; +option java_outer_classname = "CacheProto"; +option java_multiple_files = true; +option (udpa.annotations.file_migrate).move_to_package = "envoy.extensions.filters.http.cache.v3"; + +// [#protodoc-title: HTTP Cache Filter] +// HTTP Cache Filter :ref:`overview `. +// [#extension: envoy.filters.http.cache] + +// [#next-free-field: 6] +message CacheConfig { + // [#not-implemented-hide:] + // Modifies cache key creation by restricting which parts of the URL are included. + message KeyCreatorParams { + // If true, exclude the URL scheme from the cache key. Set to true if your origins always + // produce the same response for http and https requests. + bool exclude_scheme = 1; + + // If true, exclude the host from the cache key. Set to true if your origins' responses don't + // ever depend on host. + bool exclude_host = 2; + + // If *query_parameters_included* is nonempty, only query parameters matched + // by one or more of its matchers are included in the cache key. Any other + // query params will not affect cache lookup. + repeated api.v2.route.QueryParameterMatcher query_parameters_included = 3; + + // If *query_parameters_excluded* is nonempty, query parameters matched by one + // or more of its matchers are excluded from the cache key (even if also + // matched by *query_parameters_included*), and will not affect cache lookup. + repeated api.v2.route.QueryParameterMatcher query_parameters_excluded = 4; + } + + // Config specific to the cache storage implementation. + google.protobuf.Any typed_config = 1; + + // Name of cache implementation to use, as specified in the intended HttpCacheFactory + // implementation. Cache names should use reverse DNS format, though this is not enforced. + string name = 2; + + // [#not-implemented-hide:] + // + // + // List of allowed *Vary* headers. + // + // The *vary* response header holds a list of header names that affect the + // contents of a response, as described by + // https://httpwg.org/specs/rfc7234.html#caching.negotiated.responses. + // + // During insertion, *allowed_vary_headers* acts as a whitelist: if a + // response's *vary* header mentions any header names that aren't in + // *allowed_vary_headers*, that response will not be cached. + // + // During lookup, *allowed_vary_headers* controls what request headers will be + // sent to the cache storage implementation. + repeated type.matcher.StringMatcher allowed_vary_headers = 3; + + // [#not-implemented-hide:] + // + // + // Modifies cache key creation by restricting which parts of the URL are included. + KeyCreatorParams key_creator_params = 4; + + // [#not-implemented-hide:] + // + // + // Max body size the cache filter will insert into a cache. 0 means unlimited (though the cache + // storage implementation may have its own limit beyond which it will reject insertions). + uint32 max_body_bytes = 5; +} diff --git a/api/envoy/extensions/filters/http/cache/v3/BUILD b/api/envoy/extensions/filters/http/cache/v3/BUILD new file mode 100644 index 0000000000000..0fce9c8de8807 --- /dev/null +++ b/api/envoy/extensions/filters/http/cache/v3/BUILD @@ -0,0 +1,14 @@ +# DO NOT EDIT. This file is generated by tools/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/config/filter/http/cache/v2:pkg", + "//envoy/config/route/v3:pkg", + "//envoy/type/matcher/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/filters/http/cache/v3/cache.proto b/api/envoy/extensions/filters/http/cache/v3/cache.proto new file mode 100644 index 0000000000000..e45f158f5f198 --- /dev/null +++ b/api/envoy/extensions/filters/http/cache/v3/cache.proto @@ -0,0 +1,88 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.cache.v3; + +import "envoy/config/route/v3/route_components.proto"; +import "envoy/type/matcher/v3/string.proto"; + +import "google/protobuf/any.proto"; + +import "udpa/annotations/versioning.proto"; + +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.cache.v3"; +option java_outer_classname = "CacheProto"; +option java_multiple_files = true; + +// [#protodoc-title: HTTP Cache Filter] +// HTTP Cache Filter :ref:`overview `. +// [#extension: envoy.filters.http.cache] + +// [#next-free-field: 6] +message CacheConfig { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.cache.v2.CacheConfig"; + + // [#not-implemented-hide:] + // Modifies cache key creation by restricting which parts of the URL are included. + message KeyCreatorParams { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.cache.v2.CacheConfig.KeyCreatorParams"; + + // If true, exclude the URL scheme from the cache key. Set to true if your origins always + // produce the same response for http and https requests. + bool exclude_scheme = 1; + + // If true, exclude the host from the cache key. Set to true if your origins' responses don't + // ever depend on host. + bool exclude_host = 2; + + // If *query_parameters_included* is nonempty, only query parameters matched + // by one or more of its matchers are included in the cache key. Any other + // query params will not affect cache lookup. + repeated config.route.v3.QueryParameterMatcher query_parameters_included = 3; + + // If *query_parameters_excluded* is nonempty, query parameters matched by one + // or more of its matchers are excluded from the cache key (even if also + // matched by *query_parameters_included*), and will not affect cache lookup. + repeated config.route.v3.QueryParameterMatcher query_parameters_excluded = 4; + } + + // Config specific to the cache storage implementation. + google.protobuf.Any typed_config = 1; + + // Name of cache implementation to use, as specified in the intended HttpCacheFactory + // implementation. Cache names should use reverse DNS format, though this is not enforced. + string name = 2; + + // [#not-implemented-hide:] + // + // + // List of allowed *Vary* headers. + // + // The *vary* response header holds a list of header names that affect the + // contents of a response, as described by + // https://httpwg.org/specs/rfc7234.html#caching.negotiated.responses. + // + // During insertion, *allowed_vary_headers* acts as a whitelist: if a + // response's *vary* header mentions any header names that aren't in + // *allowed_vary_headers*, that response will not be cached. + // + // During lookup, *allowed_vary_headers* controls what request headers will be + // sent to the cache storage implementation. + repeated type.matcher.v3.StringMatcher allowed_vary_headers = 3; + + // [#not-implemented-hide:] + // + // + // Modifies cache key creation by restricting which parts of the URL are included. + KeyCreatorParams key_creator_params = 4; + + // [#not-implemented-hide:] + // + // + // Max body size the cache filter will insert into a cache. 0 means unlimited (though the cache + // storage implementation may have its own limit beyond which it will reject insertions). + uint32 max_body_bytes = 5; +} diff --git a/docs/root/configuration/http/http_filters/cache_filter.rst b/docs/root/configuration/http/http_filters/cache_filter.rst new file mode 100644 index 0000000000000..6584795f12999 --- /dev/null +++ b/docs/root/configuration/http/http_filters/cache_filter.rst @@ -0,0 +1,46 @@ +.. _config_http_filters_cache: + +HTTP Cache Filter +================= +.. attention:: Work in Progress--not ready for deployment + +HTTP caching can improve system throughput, latency, and network/backend load +levels when the same content is requested multiple times. Caching is +particularly valuable for edge proxies and browser-based traffic, which +typically include many cacheable static resources, but it can be useful any time +there is enough repeatedly served cacheable content. + +Configuration +------------- +* :ref:`Configuration API ` +* This filter should be configured with the name *envoy.filters.http.cache*. + +The only required configuration field is :ref:`name +`, which must +specify a valid cache storage implementation linked into your Envoy +binary. Specifying 'envoy.extensions.http.cache.simple' will select a proof-of-concept +implementation included in the Envoy source. More implementations can (and will) +be provided by implementing Envoy::Extensions::HttpFilters::Cache::HttpCache. To +write a cache storage implementation, see :repo:`Writing Cache Filter +Implementations ` + +.. TODO(toddmgreer) Describe other fields as they get implemented. + The remaining configuration fields control caching behavior and limits. By + default, this filter will cache almost all responses that are considered + cacheable by `RFC7234 `_, with handling + of conditional (`RFC7232 `_), and *range* + (`RFC7233 `_) requests. Those RFC define + which request methods and response codes are cacheable, subject to the + cache-related headers they also define: *cache-control*, *range*, *if-match*, + *if-none-match*, *if-modified-since*, *if-unmodified-since*, *if-range*, *authorization*, + *date*, *age*, *expires*, and *vary*. Responses with a *vary* header will only be cached + if the named headers are listed in :ref:`allowed_vary_headers + ` + +Status +------ +* This filter *is* ready for developers to write cache storage plugins; please + contribute them to the Envoy repository if possible. +* This filter *is* ready for contributions to help finish its implementation of + HTTP caching semantics. +* This filter *is not* ready for actual use. Please see TODOs in the code. diff --git a/docs/root/configuration/http/http_filters/http_filters.rst b/docs/root/configuration/http/http_filters/http_filters.rst index 2824c1e4c2d24..6f33a9829d210 100644 --- a/docs/root/configuration/http/http_filters/http_filters.rst +++ b/docs/root/configuration/http/http_filters/http_filters.rst @@ -8,6 +8,7 @@ HTTP filters adaptive_concurrency_filter buffer_filter + cache_filter cors_filter csrf_filter dynamic_forward_proxy_filter diff --git a/generated_api_shadow/BUILD b/generated_api_shadow/BUILD index 0d10b796cc83a..3dd5b1f5c2c7b 100644 --- a/generated_api_shadow/BUILD +++ b/generated_api_shadow/BUILD @@ -35,6 +35,7 @@ proto_library( "//envoy/config/filter/fault/v2:pkg", "//envoy/config/filter/http/adaptive_concurrency/v2alpha:pkg", "//envoy/config/filter/http/buffer/v2:pkg", + "//envoy/config/filter/http/cache/v2:pkg", "//envoy/config/filter/http/cors/v2:pkg", "//envoy/config/filter/http/csrf/v2:pkg", "//envoy/config/filter/http/dynamic_forward_proxy/v2alpha:pkg", diff --git a/generated_api_shadow/envoy/config/filter/http/cache/v2/BUILD b/generated_api_shadow/envoy/config/filter/http/cache/v2/BUILD new file mode 100644 index 0000000000000..87746bf482e03 --- /dev/null +++ b/generated_api_shadow/envoy/config/filter/http/cache/v2/BUILD @@ -0,0 +1,13 @@ +# DO NOT EDIT. This file is generated by tools/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/api/v2/route:pkg", + "//envoy/type/matcher:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/generated_api_shadow/envoy/config/filter/http/cache/v2/cache.proto b/generated_api_shadow/envoy/config/filter/http/cache/v2/cache.proto new file mode 100644 index 0000000000000..59c0ffa78b86f --- /dev/null +++ b/generated_api_shadow/envoy/config/filter/http/cache/v2/cache.proto @@ -0,0 +1,82 @@ +syntax = "proto3"; + +package envoy.config.filter.http.cache.v2; + +import "envoy/api/v2/route/route_components.proto"; +import "envoy/type/matcher/string.proto"; + +import "google/protobuf/any.proto"; + +import "udpa/annotations/migrate.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.config.filter.http.cache.v2"; +option java_outer_classname = "CacheProto"; +option java_multiple_files = true; +option (udpa.annotations.file_migrate).move_to_package = "envoy.extensions.filters.http.cache.v3"; + +// [#protodoc-title: HTTP Cache Filter] +// HTTP Cache Filter :ref:`overview `. +// [#extension: envoy.filters.http.cache] + +// [#next-free-field: 6] +message CacheConfig { + // [#not-implemented-hide:] + // Modifies cache key creation by restricting which parts of the URL are included. + message KeyCreatorParams { + // If true, exclude the URL scheme from the cache key. Set to true if your origins always + // produce the same response for http and https requests. + bool exclude_scheme = 1; + + // If true, exclude the host from the cache key. Set to true if your origins' responses don't + // ever depend on host. + bool exclude_host = 2; + + // If *query_parameters_included* is nonempty, only query parameters matched + // by one or more of its matchers are included in the cache key. Any other + // query params will not affect cache lookup. + repeated api.v2.route.QueryParameterMatcher query_parameters_included = 3; + + // If *query_parameters_excluded* is nonempty, query parameters matched by one + // or more of its matchers are excluded from the cache key (even if also + // matched by *query_parameters_included*), and will not affect cache lookup. + repeated api.v2.route.QueryParameterMatcher query_parameters_excluded = 4; + } + + // Config specific to the cache storage implementation. + google.protobuf.Any typed_config = 1; + + // Name of cache implementation to use, as specified in the intended HttpCacheFactory + // implementation. Cache names should use reverse DNS format, though this is not enforced. + string name = 2; + + // [#not-implemented-hide:] + // + // + // List of allowed *Vary* headers. + // + // The *vary* response header holds a list of header names that affect the + // contents of a response, as described by + // https://httpwg.org/specs/rfc7234.html#caching.negotiated.responses. + // + // During insertion, *allowed_vary_headers* acts as a whitelist: if a + // response's *vary* header mentions any header names that aren't in + // *allowed_vary_headers*, that response will not be cached. + // + // During lookup, *allowed_vary_headers* controls what request headers will be + // sent to the cache storage implementation. + repeated type.matcher.StringMatcher allowed_vary_headers = 3; + + // [#not-implemented-hide:] + // + // + // Modifies cache key creation by restricting which parts of the URL are included. + KeyCreatorParams key_creator_params = 4; + + // [#not-implemented-hide:] + // + // + // Max body size the cache filter will insert into a cache. 0 means unlimited (though the cache + // storage implementation may have its own limit beyond which it will reject insertions). + uint32 max_body_bytes = 5; +} diff --git a/generated_api_shadow/envoy/extensions/filters/http/cache/v3/BUILD b/generated_api_shadow/envoy/extensions/filters/http/cache/v3/BUILD new file mode 100644 index 0000000000000..0fce9c8de8807 --- /dev/null +++ b/generated_api_shadow/envoy/extensions/filters/http/cache/v3/BUILD @@ -0,0 +1,14 @@ +# DO NOT EDIT. This file is generated by tools/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/config/filter/http/cache/v2:pkg", + "//envoy/config/route/v3:pkg", + "//envoy/type/matcher/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/generated_api_shadow/envoy/extensions/filters/http/cache/v3/cache.proto b/generated_api_shadow/envoy/extensions/filters/http/cache/v3/cache.proto new file mode 100644 index 0000000000000..e45f158f5f198 --- /dev/null +++ b/generated_api_shadow/envoy/extensions/filters/http/cache/v3/cache.proto @@ -0,0 +1,88 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.cache.v3; + +import "envoy/config/route/v3/route_components.proto"; +import "envoy/type/matcher/v3/string.proto"; + +import "google/protobuf/any.proto"; + +import "udpa/annotations/versioning.proto"; + +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.cache.v3"; +option java_outer_classname = "CacheProto"; +option java_multiple_files = true; + +// [#protodoc-title: HTTP Cache Filter] +// HTTP Cache Filter :ref:`overview `. +// [#extension: envoy.filters.http.cache] + +// [#next-free-field: 6] +message CacheConfig { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.cache.v2.CacheConfig"; + + // [#not-implemented-hide:] + // Modifies cache key creation by restricting which parts of the URL are included. + message KeyCreatorParams { + option (udpa.annotations.versioning).previous_message_type = + "envoy.config.filter.http.cache.v2.CacheConfig.KeyCreatorParams"; + + // If true, exclude the URL scheme from the cache key. Set to true if your origins always + // produce the same response for http and https requests. + bool exclude_scheme = 1; + + // If true, exclude the host from the cache key. Set to true if your origins' responses don't + // ever depend on host. + bool exclude_host = 2; + + // If *query_parameters_included* is nonempty, only query parameters matched + // by one or more of its matchers are included in the cache key. Any other + // query params will not affect cache lookup. + repeated config.route.v3.QueryParameterMatcher query_parameters_included = 3; + + // If *query_parameters_excluded* is nonempty, query parameters matched by one + // or more of its matchers are excluded from the cache key (even if also + // matched by *query_parameters_included*), and will not affect cache lookup. + repeated config.route.v3.QueryParameterMatcher query_parameters_excluded = 4; + } + + // Config specific to the cache storage implementation. + google.protobuf.Any typed_config = 1; + + // Name of cache implementation to use, as specified in the intended HttpCacheFactory + // implementation. Cache names should use reverse DNS format, though this is not enforced. + string name = 2; + + // [#not-implemented-hide:] + // + // + // List of allowed *Vary* headers. + // + // The *vary* response header holds a list of header names that affect the + // contents of a response, as described by + // https://httpwg.org/specs/rfc7234.html#caching.negotiated.responses. + // + // During insertion, *allowed_vary_headers* acts as a whitelist: if a + // response's *vary* header mentions any header names that aren't in + // *allowed_vary_headers*, that response will not be cached. + // + // During lookup, *allowed_vary_headers* controls what request headers will be + // sent to the cache storage implementation. + repeated type.matcher.v3.StringMatcher allowed_vary_headers = 3; + + // [#not-implemented-hide:] + // + // + // Modifies cache key creation by restricting which parts of the URL are included. + KeyCreatorParams key_creator_params = 4; + + // [#not-implemented-hide:] + // + // + // Max body size the cache filter will insert into a cache. 0 means unlimited (though the cache + // storage implementation may have its own limit beyond which it will reject insertions). + uint32 max_body_bytes = 5; +} diff --git a/include/envoy/http/header_map.h b/include/envoy/http/header_map.h index 9c65dfa1bc9b0..76072c18b6e75 100644 --- a/include/envoy/http/header_map.h +++ b/include/envoy/http/header_map.h @@ -269,6 +269,7 @@ class HeaderEntry { HEADER_FUNC(AccessControlAllowCredentials) \ HEADER_FUNC(AccessControlExposeHeaders) \ HEADER_FUNC(AccessControlMaxAge) \ + HEADER_FUNC(Age) \ HEADER_FUNC(Authorization) \ HEADER_FUNC(CacheControl) \ HEADER_FUNC(ClientTraceId) \ @@ -307,6 +308,7 @@ class HeaderEntry { HEADER_FUNC(EnvoyUpstreamServiceTime) \ HEADER_FUNC(Etag) \ HEADER_FUNC(Expect) \ + HEADER_FUNC(Expires) \ HEADER_FUNC(ForwardedClientCert) \ HEADER_FUNC(ForwardedFor) \ HEADER_FUNC(ForwardedProto) \ diff --git a/source/common/common/logger.h b/source/common/common/logger.h index f3faa690b9394..0b791eb49c629 100644 --- a/source/common/common/logger.h +++ b/source/common/common/logger.h @@ -25,6 +25,7 @@ namespace Logger { FUNCTION(aws) \ FUNCTION(assert) \ FUNCTION(backtrace) \ + FUNCTION(cache_filter) \ FUNCTION(client) \ FUNCTION(config) \ FUNCTION(connection) \ diff --git a/source/common/http/headers.h b/source/common/http/headers.h index 1c0ffc6ed2816..90d0fa134edb7 100644 --- a/source/common/http/headers.h +++ b/source/common/http/headers.h @@ -59,6 +59,7 @@ class HeaderValues { const LowerCaseString AccessControlExposeHeaders{"access-control-expose-headers"}; const LowerCaseString AccessControlMaxAge{"access-control-max-age"}; const LowerCaseString AccessControlAllowCredentials{"access-control-allow-credentials"}; + const LowerCaseString Age{"age"}; const LowerCaseString Authorization{"authorization"}; const LowerCaseString ProxyAuthenticate{"proxy-authenticate"}; const LowerCaseString ProxyAuthorization{"proxy-authorization"}; @@ -117,6 +118,7 @@ class HeaderValues { const LowerCaseString EnvoyDecoratorOperation{absl::StrCat(prefix(), "-decorator-operation")}; const LowerCaseString Etag{"etag"}; const LowerCaseString Expect{"expect"}; + const LowerCaseString Expires{"expires"}; const LowerCaseString ForwardedClientCert{"x-forwarded-client-cert"}; const LowerCaseString ForwardedFor{"x-forwarded-for"}; const LowerCaseString ForwardedHost{"x-forwarded-host"}; @@ -171,6 +173,7 @@ class HeaderValues { const std::string NoCache{"no-cache"}; const std::string NoCacheMaxAge0{"no-cache, max-age=0"}; const std::string NoTransform{"no-transform"}; + const std::string Private{"private"}; } CacheControlValues; struct { diff --git a/source/docs/cache_filter_flow.png b/source/docs/cache_filter_flow.png new file mode 100644 index 0000000000000..d1d254c5d9947 Binary files /dev/null and b/source/docs/cache_filter_flow.png differ diff --git a/source/docs/cache_filter_plugins.md b/source/docs/cache_filter_plugins.md new file mode 100644 index 0000000000000..51798ccccd0d9 --- /dev/null +++ b/source/docs/cache_filter_plugins.md @@ -0,0 +1,48 @@ +### Overview + +The HTTP Cache Filter handles most of the complexity of HTTP caching semantics, +but delegates the actual storage of HTTP responses to implementations of the +HttpCache interface. These implementations can cover all points on the spectrum +of persistence, performance, and distribution, from local RAM caches to globally +distributed persistent caches. They can be fully custom caches, or +wrappers/adapters around local or remote open-source or proprietary caches. + +If you write a new cache storage implementation, please add it to the Envoy +repository if possible. This is the only way to make sure it stays up-to-date +with Envoy changes, and lets other developers contribute fixes and improvements. + +As you read this, also read the example implementation in `simple_http_cache.h/.cc`. + +You need to write implementations of four small interfaces: + +## HttpCache + * Example Implementation: `SimpleHttpCache` + * `HttpCache` represents an actual cache of responses stored somewhere. It provides methods to set up cache lookups and inserts, and to update the headers of cached responses. + +## HttpCacheFactory + * Example Implementation: `SimpleHttpCacheFactory` + * `HttpCacheFactory` does what it sounds like: it creates HttpCache implementations, based on a name that came from the cache filter's config. + +## LookupContext + * Example Implementation: `SimpleLookupContext` + * `LookupContext` represents a single lookup operation; this is a good place to store whatever per-lookup state you may need during the lookup process. + +## InsertContext + * Example Implementation: `SimpleInsertContext` + * `LookupContext` represents a single insert operation; this is a good place to store whatever per-insert state you may need during the lookup process. + +### Flow + +To initiate a lookup on an HttpCache implementation, the cache filter calls +`HttpCache::makeLookupContext`, which should return a `LookupContextPtr`. The cache filter will +call `LookupContext::getHeaders` to find out if there's a cached response. If +a result is found, the `LookupContext` implementation must call +`LookupRequest::makeLookupResult`, and pass the result to the callback. + +The cache filter will then make a series of `getBody` requests followed by `getTrailers` (if needed). + +If the `LookupResult` in the callback indicates that a response wasn't found, the cache filter will let the request pass upstream. If the origin replies with a cacheable response, the filter will call `HttpCache::makeInsertContext`, and use its methods to insert the response. + +The following diagram shows a potential GET request for a 5M resource that is present and fresh in the cache, with no trailers. In the case of a synchronous in-memory cache, this all happens within `CacheFilter::decodeHeaders`. Solid arrows denote synchronous function calls, while dashed arrows denote asynchronous function calls or their callbacks. Objects that are part of the cache implementation (`HttpCache` and `LookupContext`) are blue. (Other objects are part of the cache filter, or of Envoy. + +![Cache Filter flow diagram](cache_filter_flow.png) diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index 69ff0bc9df3b6..523c4cb39c5bf 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -35,6 +35,7 @@ EXTENSIONS = { "envoy.filters.http.adaptive_concurrency": "//source/extensions/filters/http/adaptive_concurrency:config", "envoy.filters.http.buffer": "//source/extensions/filters/http/buffer:config", + "envoy.filters.http.cache": "//source/extensions/filters/http/cache:config", "envoy.filters.http.cors": "//source/extensions/filters/http/cors:config", "envoy.filters.http.csrf": "//source/extensions/filters/http/csrf:config", "envoy.filters.http.dynamic_forward_proxy": "//source/extensions/filters/http/dynamic_forward_proxy:config", diff --git a/source/extensions/filters/http/cache/BUILD b/source/extensions/filters/http/cache/BUILD new file mode 100644 index 0000000000000..0cce18df0d32b --- /dev/null +++ b/source/extensions/filters/http/cache/BUILD @@ -0,0 +1,100 @@ +licenses(["notice"]) # Apache 2 + +## Pluggable HTTP cache filter + +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_package", + "envoy_proto_library", +) + +envoy_package() + +envoy_cc_library( + name = "cache_filter_lib", + srcs = ["cache_filter.cc"], + hdrs = ["cache_filter.h"], + deps = [ + ":http_cache_lib", + "//include/envoy/registry", + "//source/common/common:logger_lib", + "//source/common/common:macros", + "//source/common/config:utility_lib", + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "@envoy_api//envoy/config/filter/http/cache/v2:pkg_cc_proto", + ], +) + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + security_posture = "robust_to_untrusted_downstream_and_upstream", + status = "wip", + deps = [ + ":cache_filter_lib", + ":key_cc_proto", + "//include/envoy/common:time_interface", + "//include/envoy/registry", + "//include/envoy/stats:stats_interface", + "//source/extensions/filters/http:well_known_names", + "//source/extensions/filters/http/common:factory_base_lib", + "@envoy_api//envoy/config/filter/http/cache/v2:pkg_cc_proto", + ], +) + +envoy_proto_library( + name = "key", + srcs = ["key.proto"], +) + +envoy_cc_library( + name = "http_cache_lib", + srcs = ["http_cache.cc"], + hdrs = ["http_cache.h"], + deps = [ + ":http_cache_utils_lib", + ":key_cc_proto", + "//include/envoy/buffer:buffer_interface", + "//include/envoy/common:time_interface", + "//include/envoy/config:typed_config_interface", + "//include/envoy/http:header_map_interface", + "//source/common/common:assert_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf:utility_lib", + "@envoy_api//envoy/config/filter/http/cache/v2:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "http_cache_utils_lib", + srcs = ["http_cache_utils.cc"], + hdrs = ["http_cache_utils.h"], + deps = [ + "//include/envoy/common:time_interface", + "//include/envoy/http:header_map_interface", + ], +) + +envoy_cc_library( + name = "simple_http_cache_lib", + srcs = ["simple_http_cache.cc"], + hdrs = ["simple_http_cache.h"], + deps = [ + ":http_cache_lib", + "//include/envoy/registry", + "//include/envoy/runtime:runtime_interface", + "//source/common/buffer:buffer_lib", + "//source/common/common:macros", + "//source/common/common:thread_lib", + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/common/protobuf", + "//source/extensions/filters/http/common:pass_through_filter_lib", + ], +) diff --git a/source/extensions/filters/http/cache/cache_filter.cc b/source/extensions/filters/http/cache/cache_filter.cc new file mode 100644 index 0000000000000..156c0d2991902 --- /dev/null +++ b/source/extensions/filters/http/cache/cache_filter.cc @@ -0,0 +1,209 @@ +#include "extensions/filters/http/cache/cache_filter.h" + +#include "envoy/registry/registry.h" + +#include "common/config/utility.h" +#include "common/http/headers.h" + +#include "absl/memory/memory.h" +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +bool CacheFilter::isCacheableRequest(Http::HeaderMap& headers) { + const Http::HeaderEntry* method = headers.Method(); + const Http::HeaderEntry* forwarded_proto = headers.ForwardedProto(); + const Http::HeaderValues& header_values = Http::Headers::get(); + // TODO(toddmgreer): Also serve HEAD requests from cache. + // TODO(toddmgreer): Check all the other cache-related headers. + return method && forwarded_proto && headers.Path() && headers.Host() && + (method->value() == header_values.MethodValues.Get) && + (forwarded_proto->value() == header_values.SchemeValues.Http || + forwarded_proto->value() == header_values.SchemeValues.Https); +} + +bool CacheFilter::isCacheableResponse(Http::HeaderMap& headers) { + const Http::HeaderEntry* cache_control = headers.CacheControl(); + // TODO(toddmgreer): fully check for cacheability. See for example + // https://github.com/apache/incubator-pagespeed-mod/blob/master/pagespeed/kernel/http/caching_headers.h. + if (cache_control) { + return !StringUtil::caseFindToken(cache_control->value().getStringView(), ",", + Http::Headers::get().CacheControlValues.Private); + } + return false; +} + +HttpCache& +CacheFilter::getCache(const envoy::config::filter::http::cache::v2::CacheConfig& config) { + return Config::Utility::getAndCheckFactory(config).getCache(config); +} + +CacheFilter::CacheFilter(const envoy::config::filter::http::cache::v2::CacheConfig& config, + const std::string&, Stats::Scope&, TimeSource& time_source) + : time_source_(time_source), cache_(getCache(config)) {} + +void CacheFilter::onDestroy() { + lookup_ = nullptr; + insert_ = nullptr; +} + +Http::FilterHeadersStatus CacheFilter::decodeHeaders(Http::HeaderMap& headers, bool) { + ENVOY_STREAM_LOG(debug, "CacheFilter::decodeHeaders: {}", *decoder_callbacks_, headers); + if (!isCacheableRequest(headers)) { + ENVOY_STREAM_LOG(debug, "CacheFilter::decodeHeaders ignoring uncacheable request: {}", + *decoder_callbacks_, headers); + return Http::FilterHeadersStatus::Continue; + } + ASSERT(decoder_callbacks_); + lookup_ = cache_.makeLookupContext(LookupRequest(headers, time_source_.systemTime())); + ASSERT(lookup_); + + CacheFilterSharedPtr self = shared_from_this(); + ENVOY_STREAM_LOG(debug, "CacheFilter::decodeHeaders starting lookup", *decoder_callbacks_); + lookup_->getHeaders([self](LookupResult&& result) { onHeadersAsync(self, std::move(result)); }); + return Http::FilterHeadersStatus::StopIteration; +} + +Http::FilterHeadersStatus CacheFilter::encodeHeaders(Http::HeaderMap& headers, bool end_stream) { + if (lookup_ && isCacheableResponse(headers)) { + ENVOY_STREAM_LOG(debug, "CacheFilter::encodeHeaders inserting headers", *encoder_callbacks_); + insert_ = cache_.makeInsertContext(std::move(lookup_)); + insert_->insertHeaders(headers, end_stream); + } + return Http::FilterHeadersStatus::Continue; +} + +Http::FilterDataStatus CacheFilter::encodeData(Buffer::Instance& data, bool end_stream) { + if (insert_) { + ENVOY_STREAM_LOG(debug, "CacheFilter::encodeHeaders inserting body", *encoder_callbacks_); + // TODO(toddmgreer): Wait for the cache if necessary. + insert_->insertBody( + data, [](bool) {}, end_stream); + } + return Http::FilterDataStatus::Continue; +} + +void CacheFilter::onOkHeaders(Http::HeaderMapPtr&& headers, + std::vector&& /*response_ranges*/, + uint64_t content_length, bool has_trailers) { + if (!lookup_) { + return; + } + response_has_trailers_ = has_trailers; + const bool end_stream = (content_length == 0 && !response_has_trailers_); + // TODO(toddmgreer): Calculate age per https://httpwg.org/specs/rfc7234.html#age.calculations + headers->addReferenceKey(Http::Headers::get().Age, 0); + decoder_callbacks_->encodeHeaders(std::move(headers), end_stream); + if (end_stream) { + return; + } + if (content_length > 0) { + remaining_body_.emplace_back(0, content_length); + getBody(); + } else { + lookup_->getTrailers([self = shared_from_this()](Http::HeaderMapPtr&& trailers) { + onTrailersAsync(self, std::move(trailers)); + }); + } +} + +void CacheFilter::onUnusableHeaders() { + if (lookup_) { + decoder_callbacks_->continueDecoding(); + } +} + +void CacheFilter::onHeadersAsync(const CacheFilterSharedPtr& self, LookupResult&& result) { + switch (result.cache_entry_status_) { + case CacheEntryStatus::RequiresValidation: + case CacheEntryStatus::FoundNotModified: + case CacheEntryStatus::UnsatisfiableRange: + ASSERT(false); // We don't yet return or support these codes. + FALLTHRU; + case CacheEntryStatus::Unusable: { + self->post([self] { self->onUnusableHeaders(); }); + return; + } + case CacheEntryStatus::Ok: + self->post([self, headers = result.headers_.release(), + response_ranges = std::move(result.response_ranges_), + content_length = result.content_length_, + has_trailers = result.has_trailers_]() mutable { + self->onOkHeaders(absl::WrapUnique(headers), std::move(response_ranges), content_length, + has_trailers); + }); + } +} + +void CacheFilter::getBody() { + ASSERT(!remaining_body_.empty(), "No reason to call getBody when there's no body to get."); + CacheFilterSharedPtr self = shared_from_this(); + lookup_->getBody(remaining_body_[0], + [self](Buffer::InstancePtr&& body) { self->onBody(std::move(body)); }); +} + +void CacheFilter::onBodyAsync(const CacheFilterSharedPtr& self, Buffer::InstancePtr&& body) { + self->post([self, body = body.release()] { self->onBody(absl::WrapUnique(body)); }); +} + +// TODO(toddmgreer): Handle downstream backpressure. +void CacheFilter::onBody(Buffer::InstancePtr&& body) { + if (!lookup_) { + return; + } + if (remaining_body_.empty()) { + ASSERT(false, "CacheFilter doesn't call getBody unless there's more body to get, so this is a " + "bogus callback."); + decoder_callbacks_->resetStream(); + return; + } + + if (!body) { + ASSERT(false, "Cache said it had a body, but isn't giving it to us."); + decoder_callbacks_->resetStream(); + return; + } + + const uint64_t bytes_from_cache = body->length(); + if (bytes_from_cache < remaining_body_[0].length()) { + remaining_body_[0].trimFront(bytes_from_cache); + } else if (bytes_from_cache == remaining_body_[0].length()) { + remaining_body_.erase(remaining_body_.begin()); + } else { + ASSERT(false, "Received oversized body from cache."); + decoder_callbacks_->resetStream(); + return; + } + + const bool end_stream = remaining_body_.empty() && !response_has_trailers_; + decoder_callbacks_->encodeData(*body, end_stream); + if (!remaining_body_.empty()) { + getBody(); + } else if (response_has_trailers_) { + lookup_->getTrailers([self = shared_from_this()](Http::HeaderMapPtr&& trailers) { + onTrailersAsync(self, std::move(trailers)); + }); + } +} + +void CacheFilter::onTrailers(Http::HeaderMapPtr&& trailers) { + if (lookup_) { + decoder_callbacks_->encodeTrailers(std::move(trailers)); + } +} + +void CacheFilter::onTrailersAsync(const CacheFilterSharedPtr& self, Http::HeaderMapPtr&& trailers) { + self->post( + [self, trailers = trailers.release()] { self->onTrailers(absl::WrapUnique(trailers)); }); +} + +void CacheFilter::post(std::function f) const { + decoder_callbacks_->dispatcher().post(std::move(f)); +} +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/cache_filter.h b/source/extensions/filters/http/cache/cache_filter.h new file mode 100644 index 0000000000000..8e68342b1edec --- /dev/null +++ b/source/extensions/filters/http/cache/cache_filter.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include + +#include "envoy/config/filter/http/cache/v2/cache.pb.h" + +#include "common/common/logger.h" + +#include "extensions/filters/http/cache/http_cache.h" +#include "extensions/filters/http/common/pass_through_filter.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +/** + * A filter that caches responses and attempts to satisfy requests from cache. + * It also inherits from std::enable_shared_from_this so it can pass shared_ptrs to async methods, + * to ensure that it doesn't get destroyed before they complete. + */ +class CacheFilter; +using CacheFilterSharedPtr = std::shared_ptr; +class CacheFilter : public Http::PassThroughFilter, + public Logger::Loggable, + public std::enable_shared_from_this { +public: + // Throws ProtoValidationException if no registered HttpCacheFactory for config.name. + static CacheFilterSharedPtr + make(const envoy::config::filter::http::cache::v2::CacheConfig& config, + const std::string& stats_prefix, Stats::Scope& scope, TimeSource& time_source) { + // Can't use make_shared due to private constructor. + return std::shared_ptr(new CacheFilter(config, stats_prefix, scope, time_source)); + } + // Http::StreamFilterBase + void onDestroy() override; + // Http::StreamDecoderFilter + Http::FilterHeadersStatus decodeHeaders(Http::HeaderMap& headers, bool end_stream) override; + // Http::StreamEncoderFilter + Http::FilterHeadersStatus encodeHeaders(Http::HeaderMap& headers, bool end_stream) override; + Http::FilterDataStatus encodeData(Buffer::Instance& buffer, bool end_stream) override; + +private: + // Throws EnvoyException if no registered HttpCacheFactory for config.name. + // Constructor is private to enforce enable_shared_from_this's requirement that this must be owned + // by a shared_ptr. + CacheFilter(const envoy::config::filter::http::cache::v2::CacheConfig& config, + const std::string& stats_prefix, Stats::Scope& scope, TimeSource& time_source); + + void getBody(); + void onOkHeaders(Http::HeaderMapPtr&& headers, std::vector&& response_ranges, + uint64_t content_length, bool has_trailers); + void onUnusableHeaders(); + void onBody(Buffer::InstancePtr&& body); + void onTrailers(Http::HeaderMapPtr&& trailers); + static void onHeadersAsync(const CacheFilterSharedPtr& self, LookupResult&& result); + static void onBodyAsync(const CacheFilterSharedPtr& self, Buffer::InstancePtr&& body); + static void onTrailersAsync(const CacheFilterSharedPtr& self, Http::HeaderMapPtr&& trailers); + void post(std::function f) const; + + // These don't require private access, but are members per envoy convention. + static bool isCacheableRequest(Http::HeaderMap& headers); + static bool isCacheableResponse(Http::HeaderMap& headers); + static HttpCache& getCache(const envoy::config::filter::http::cache::v2::CacheConfig& config); + + TimeSource& time_source_; + HttpCache& cache_; + LookupContextPtr lookup_; + InsertContextPtr insert_; + + // Tracks what body bytes still need to be read from the cache. This is + // currently only one Range, but will expand when full range support is added. Initialized by + // onOkHeaders. + std::vector remaining_body_; + + // True if the response has trailers. + // TODO(toddmgreer): cache trailers. + bool response_has_trailers_; +}; + +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/config.cc b/source/extensions/filters/http/cache/config.cc new file mode 100644 index 0000000000000..c043cf2cd4e1e --- /dev/null +++ b/source/extensions/filters/http/cache/config.cc @@ -0,0 +1,31 @@ +#include "extensions/filters/http/cache/config.h" + +#include + +#include "envoy/common/time.h" +#include "envoy/config/filter/http/cache/v2/cache.pb.validate.h" +#include "envoy/registry/registry.h" +#include "envoy/stats/scope.h" + +#include "extensions/filters/http/cache/cache_filter.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +Http::FilterFactoryCb CacheFilterFactory::createFilterFactoryFromProtoTyped( + const envoy::config::filter::http::cache::v2::CacheConfig& config, + const std::string& stats_prefix, Server::Configuration::FactoryContext& context) { + return [config, stats_prefix, &context](Http::FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addStreamFilter( + CacheFilter::make(config, stats_prefix, context.scope(), context.timeSource())); + }; +} + +REGISTER_FACTORY(CacheFilterFactory, Server::Configuration::NamedHttpFilterConfigFactory); + +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/config.h b/source/extensions/filters/http/cache/config.h new file mode 100644 index 0000000000000..d740194676925 --- /dev/null +++ b/source/extensions/filters/http/cache/config.h @@ -0,0 +1,29 @@ +#pragma once + +#include "envoy/config/filter/http/cache/v2/cache.pb.h" +#include "envoy/config/filter/http/cache/v2/cache.pb.validate.h" + +#include "extensions/filters/http/cache/cache_filter.h" +#include "extensions/filters/http/common/factory_base.h" +#include "extensions/filters/http/well_known_names.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +class CacheFilterFactory + : public Common::FactoryBase { +public: + CacheFilterFactory() : FactoryBase(HttpFilterNames::get().Cache) {} + +private: + Http::FilterFactoryCb createFilterFactoryFromProtoTyped( + const envoy::config::filter::http::cache::v2::CacheConfig& config, + const std::string& stats_prefix, Server::Configuration::FactoryContext& context) override; +}; + +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/http_cache.cc b/source/extensions/filters/http/cache/http_cache.cc new file mode 100644 index 0000000000000..03727f419f029 --- /dev/null +++ b/source/extensions/filters/http/cache/http_cache.cc @@ -0,0 +1,157 @@ +#include "extensions/filters/http/cache/http_cache.h" + +#include +#include + +#include "common/http/headers.h" +#include "common/protobuf/utility.h" + +#include "extensions/filters/http/cache/http_cache_utils.h" + +#include "absl/time/time.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +std::ostream& operator<<(std::ostream& os, CacheEntryStatus status) { + switch (status) { + case CacheEntryStatus::Ok: + return os << "Ok"; + case CacheEntryStatus::Unusable: + return os << "Unusable"; + case CacheEntryStatus::RequiresValidation: + return os << "RequiresValidation"; + case CacheEntryStatus::FoundNotModified: + return os << "FoundNotModified"; + case CacheEntryStatus::UnsatisfiableRange: + return os << "UnsatisfiableRange"; + } + ASSERT(false, "Unreachable"); + return os; +} + +std::ostream& operator<<(std::ostream& os, const AdjustedByteRange& range) { + return os << "[" << range.begin() << "," << range.end() << ")"; +} + +LookupRequest::LookupRequest(const Http::HeaderMap& request_headers, SystemTime timestamp) + : timestamp_(timestamp), + request_cache_control_(request_headers.CacheControl() == nullptr + ? "" + : request_headers.CacheControl()->value().getStringView()) { + ASSERT(request_headers.Path(), "Can't form cache lookup key for malformed Http::HeaderMap " + "with null Path."); + ASSERT(request_headers.ForwardedProto(), + "Can't form cache lookup key for malformed Http::HeaderMap with null ForwardedProto."); + ASSERT(request_headers.Host(), "Can't form cache lookup key for malformed Http::HeaderMap " + "with null Host."); + const Http::HeaderString& forwarded_proto = request_headers.ForwardedProto()->value(); + const auto& scheme_values = Http::Headers::get().SchemeValues; + ASSERT(forwarded_proto == scheme_values.Http || forwarded_proto == scheme_values.Https); + // TODO(toddmgreer): Let config determine whether to include forwarded_proto, host, and + // query params. + // TODO(toddmgreer): get cluster name. + // TODO(toddmgreer): Parse Range header into request_range_spec_, and handle the resultant + // vector in CacheFilter::onOkHeaders. + key_.set_cluster_name("cluster_name_goes_here"); + key_.set_host(std::string(request_headers.Host()->value().getStringView())); + key_.set_path(std::string(request_headers.Path()->value().getStringView())); + key_.set_clear_http(forwarded_proto == scheme_values.Http); +} + +// Unless this API is still alpha, calls to stableHashKey() must always return +// the same result, or a way must be provided to deal with a complete cache +// flush. localHashKey however, can be changed at will. +size_t stableHashKey(const Key& key) { return MessageUtil::hash(key); } +size_t localHashKey(const Key& key) { return stableHashKey(key); } + +// Returns true if response_headers is fresh. +bool LookupRequest::isFresh(const Http::HeaderMap& response_headers) const { + if (!response_headers.Date()) { + return false; + } + const Http::HeaderEntry* cache_control_header = response_headers.CacheControl(); + if (cache_control_header) { + const SystemTime::duration effective_max_age = + Utils::effectiveMaxAge(cache_control_header->value().getStringView()); + return timestamp_ - Utils::httpTime(response_headers.Date()) < effective_max_age; + } + // We didn't find a cache-control header with enough info to determine + // freshness, so fall back to the expires header. + return timestamp_ <= Utils::httpTime(response_headers.Expires()); +} + +LookupResult LookupRequest::makeLookupResult(Http::HeaderMapPtr&& response_headers, + uint64_t content_length) const { + // TODO(toddmgreer): Implement all HTTP caching semantics. + ASSERT(response_headers); + LookupResult result; + result.cache_entry_status_ = + isFresh(*response_headers) ? CacheEntryStatus::Ok : CacheEntryStatus::RequiresValidation; + result.headers_ = std::move(response_headers); + result.content_length_ = content_length; + if (!adjustByteRangeSet(result.response_ranges_, request_range_spec_, content_length)) { + result.headers_->setStatus(416); // Range Not Satisfiable + } + result.has_trailers_ = false; + return result; +} + +bool adjustByteRangeSet(std::vector& response_ranges, + const std::vector& request_range_spec, + uint64_t content_length) { + if (request_range_spec.empty()) { + // No range header, so the request can proceed. + return true; + } + + if (content_length == 0) { + // There is a range header, but it's unsatisfiable. + return false; + } + + for (const RawByteRange& spec : request_range_spec) { + if (spec.isSuffix()) { + // spec is a suffix-byte-range-spec + if (spec.suffixLength() == 0) { + // This range is unsatisfiable, so skip it. + continue; + } + if (spec.suffixLength() >= content_length) { + // All bytes are being requested, so we may as well send a '200 + // OK' response. + response_ranges.clear(); + return true; + } + response_ranges.emplace_back(content_length - spec.suffixLength(), content_length); + } else { + // spec is a byte-range-spec + if (spec.firstBytePos() >= content_length) { + // This range is unsatisfiable, so skip it. + continue; + } + if (spec.lastBytePos() >= content_length - 1) { + if (spec.firstBytePos() == 0) { + // All bytes are being requested, so we may as well send a '200 + // OK' response. + response_ranges.clear(); + return true; + } + response_ranges.emplace_back(spec.firstBytePos(), content_length); + } else { + response_ranges.emplace_back(spec.firstBytePos(), spec.lastBytePos() + 1); + } + } + } + if (response_ranges.empty()) { + // All ranges were unsatisfiable. + return false; + } + return true; +} +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/http_cache.h b/source/extensions/filters/http/cache/http_cache.h new file mode 100644 index 0000000000000..743959e90edd9 --- /dev/null +++ b/source/extensions/filters/http/cache/http_cache.h @@ -0,0 +1,324 @@ +#pragma once + +#include +#include +#include + +#include "envoy/buffer/buffer.h" +#include "envoy/common/time.h" +#include "envoy/config/filter/http/cache/v2/cache.pb.h" +#include "envoy/config/typed_config.h" +#include "envoy/http/header_map.h" + +#include "common/common/assert.h" + +#include "source/extensions/filters/http/cache/key.pb.h" + +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +// Whether a given cache entry is good for the current request. +enum class CacheEntryStatus { + // This entry is fresh, and an appropriate response to the request. + Ok, + // No usable entry was found. If this was generated for a cache entry, the + // cache should delete that entry. + Unusable, + // This entry is stale, but appropriate for validating + RequiresValidation, + // This entry is fresh, and an appropriate basis for a 304 Not Modified + // response. + FoundNotModified, + // This entry is fresh, but can't satisfy the requested range(s). + UnsatisfiableRange, +}; + +std::ostream& operator<<(std::ostream& os, CacheEntryStatus status); + +// Byte range from an HTTP request. +class RawByteRange { +public: + // - If first==UINT64_MAX, construct a RawByteRange requesting the final last body bytes. + // - Otherwise, construct a RawByteRange requesting the [first,last] body bytes. + // Prereq: first == UINT64_MAX || first <= last + // Invariant: isSuffix() || firstBytePos() <= lastBytePos + // Examples: RawByteRange(0,4) requests the first 5 bytes. + // RawByteRange(UINT64_MAX,4) requests the last 4 bytes. + RawByteRange(uint64_t first, uint64_t last) : first_byte_pos_(first), last_byte_pos_(last) { + RELEASE_ASSERT(isSuffix() || first <= last, "Illegal byte range."); + } + bool isSuffix() const { return first_byte_pos_ == UINT64_MAX; } + uint64_t firstBytePos() const { + ASSERT(!isSuffix()); + return first_byte_pos_; + } + uint64_t lastBytePos() const { + ASSERT(!isSuffix()); + return last_byte_pos_; + } + uint64_t suffixLength() const { + ASSERT(isSuffix()); + return last_byte_pos_; + } + +private: + uint64_t first_byte_pos_; + uint64_t last_byte_pos_; +}; + +// Byte range from an HTTP request, adjusted for a known response body size, and converted from an +// HTTP-style closed interval to a C++ style half-open interval. +class AdjustedByteRange { +public: + // Construct an AdjustedByteRange representing the [first,last) bytes in the + // response body. Prereq: first <= last Invariant: begin() <= end() + // Example: AdjustedByteRange(0,4) represents the first 4 bytes. + AdjustedByteRange(uint64_t first, uint64_t last) : first_(first), last_(last) { + ASSERT(first < last, "Illegal byte range."); + } + uint64_t begin() const { return first_; } + // Unlike RawByteRange, end() is one past the index of the last offset. + uint64_t end() const { return last_; } + uint64_t length() const { return last_ - first_; } + void trimFront(uint64_t n) { + RELEASE_ASSERT(n <= length(), "Attempt to trim too much from range."); + first_ += n; + } + +private: + uint64_t first_; + uint64_t last_; +}; + +inline bool operator==(const AdjustedByteRange& lhs, const AdjustedByteRange& rhs) { + return lhs.begin() == rhs.begin() && lhs.end() == rhs.end(); +} + +std::ostream& operator<<(std::ostream& os, const AdjustedByteRange& range); + +// Adjusts request_range_spec to fit a cached response of size content_length, putting the results +// in response_ranges. Returns true if response_ranges is satisfiable (empty is considered +// satisfiable, as it denotes the entire body). +// TODO(toddmgreer): Merge/reorder ranges where appropriate. +bool adjustByteRangeSet(std::vector& response_ranges, + const std::vector& request_range_spec, + uint64_t content_length); + +// Result of a lookup operation, including cached headers and information needed +// to serve a response based on it, or to attempt to validate. +struct LookupResult { + // If cache_entry_status_ == Unusable, none of the other members are + // meaningful. + CacheEntryStatus cache_entry_status_ = CacheEntryStatus::Unusable; + + // Headers of the cached response. + Http::HeaderMapPtr headers_; + + // Size of the full response body. Cache filter will generate a content-length + // header with this value, replacing any preexisting content-length header. + // (This lets us dechunk responses as we insert them, then later serve them + // with a content-length header.) + uint64_t content_length_; + + // Represents the subset of the cached response body that should be served to + // the client. If response_ranges.empty(), the entire body should be served. + // Otherwise, each Range in response_ranges specifies an exact set of bytes to + // serve from the cached response's body. All byte positions in + // response_ranges must be in the range [0,content_length). Caches should + // ensure that they can efficiently serve these ranges, and may merge and/or + // reorder ranges as appropriate, or may clear() response_ranges entirely. + std::vector response_ranges_; + + // TODO(toddmgreer): Implement trailer support. + // True if the cached response has trailers. + bool has_trailers_ = false; +}; + +// Produces a hash of key that is consistent across restarts, architectures, +// builds, and configurations. Caches that store persistent entries based on a +// 64-bit hash should (but are not required to) use stableHashKey. Once this API +// leaves alpha, any improvements to stableHashKey that would change its output +// for existing callers is a breaking change. +// +// For non-persistent storage, use MessageUtil, which has no long-term stability +// guarantees. +// +// When providing a cached response, Caches must ensure that the keys (and not +// just their hashes) match. +// +// TODO(toddmgreer): Ensure that stability guarantees above are accurate. +size_t stableHashKey(const Key& key); + +// LookupRequest holds everything about a request that's needed to look for a +// response in a cache, to evaluate whether an entry from a cache is usable, and +// to determine what ranges are needed. +class LookupRequest { +public: + using HeaderVector = std::vector; + + // Prereq: request_headers's Path(), Scheme(), and Host() are non-null. + LookupRequest(const Http::HeaderMap& request_headers, SystemTime timestamp); + + // Caches may modify the key according to local needs, though care must be + // taken to ensure that meaningfully distinct responses have distinct keys. + const Key& key() const { return key_; } + Key& key() { return key_; } + + // Returns the subset of this request's headers that are listed in + // envoy::config::filter::http::cache::v2alpha::CacheConfig::allowed_vary_headers. If a cache + // storage implementation forwards lookup requests to a remote cache server that supports *vary* + // headers, that server may need to see these headers. For local implementations, it may be + // simpler to instead call makeLookupResult with each potential response. + HeaderVector& vary_headers() { return vary_headers_; } + const HeaderVector& vary_headers() const { return vary_headers_; } + + // Time when this LookupRequest was created (in response to an HTTP request). + SystemTime timestamp() const { return timestamp_; } + + // WARNING: Incomplete--do not use in production (yet). + // Returns a LookupResult suitable for sending to the cache filter's + // LookupHeadersCallback. Specifically, + // - LookupResult::cache_entry_status_ is set according to HTTP cache + // validation logic. + // - LookupResult::headers takes ownership of response_headers. + // - LookupResult::content_length == content_length. + // - LookupResult::response_ranges entries are satisfiable (as documented + // there). + LookupResult makeLookupResult(Http::HeaderMapPtr&& response_headers, + uint64_t content_length) const; + +private: + bool isFresh(const Http::HeaderMap& response_headers) const; + + Key key_; + std::vector request_range_spec_; + SystemTime timestamp_; + HeaderVector vary_headers_; + const std::string request_cache_control_; +}; + +// Statically known information about a cache. +struct CacheInfo { + absl::string_view name_; + bool supports_range_requests_ = false; +}; + +using LookupBodyCallback = std::function; +using LookupHeadersCallback = std::function; +using LookupTrailersCallback = std::function; +using InsertCallback = std::function; + +// Manages the lifetime of an insertion. +class InsertContext { +public: + // Accepts response_headers for caching. Only called once. + virtual void insertHeaders(const Http::HeaderMap& response_headers, bool end_stream) PURE; + + // The insertion is streamed into the cache in chunks whose size is determined + // by the client, but with a pace determined by the cache. To avoid streaming + // data into cache too fast for the cache to handle, clients should wait for + // the cache to call ready_for_next_chunk() before streaming the next chunk. + // + // The client can abort the streaming insertion by dropping the + // InsertContextPtr. A cache can abort the insertion by passing 'false' into + // ready_for_next_chunk. + virtual void insertBody(const Buffer::Instance& chunk, InsertCallback ready_for_next_chunk, + bool end_stream) PURE; + + // Inserts trailers into the cache. + virtual void insertTrailers(const Http::HeaderMap& trailers) PURE; + + virtual ~InsertContext() = default; +}; +using InsertContextPtr = std::unique_ptr; + +// Lookup context manages the lifetime of a lookup, helping clients to pull data +// from the cache at a pace that works for them. At any time a client can abort +// an in-progress lookup by simply dropping the LookupContextPtr. +class LookupContext { +public: + virtual ~LookupContext() = default; + + // Get the headers from the cache. It is a programming error to call this + // twice. + virtual void getHeaders(LookupHeadersCallback&& cb) PURE; + + // Reads the next chunk from the cache, calling cb when the chunk is ready. + // + // The cache must call cb with a range of bytes starting at range.start() and + // ending at or before range.end(). Caller is responsible for tracking what + // ranges have been received, what to request next, and when to stop. A cache + // can report an error, and cause the response to be aborted, by calling cb + // with nullptr. + // + // If a cache happens to load data in chunks of a set size, it may be + // efficient to respond with fewer than the requested number of bytes. For + // example, assuming a 23 byte full-bodied response from a cache that reads in + // absurdly small 10 byte chunks: + // + // getBody requests bytes 0-23 .......... callback with bytes 0-9 + // getBody requests bytes 10-23 .......... callback with bytes 10-19 + // getBody requests bytes 20-23 .......... callback with bytes 20-23 + virtual void getBody(const AdjustedByteRange& range, LookupBodyCallback&& cb) PURE; + + // Get the trailers from the cache. Only called if LookupResult::has_trailers + // == true. + virtual void getTrailers(LookupTrailersCallback&& cb) PURE; +}; +using LookupContextPtr = std::unique_ptr; + +// Implement this interface to provide a cache implementation for use by +// CacheFilter. +class HttpCache { +public: + // Returns a LookupContextPtr to manage the state of a cache lookup. On a cache + // miss, the returned LookupContext will be given to the insert call (if any). + virtual LookupContextPtr makeLookupContext(LookupRequest&& request) PURE; + + // Returns an InsertContextPtr to manage the state of a cache insertion. + // Responses with a chunked transfer-encoding must be dechunked before + // insertion. + virtual InsertContextPtr makeInsertContext(LookupContextPtr&& lookup_context) PURE; + + // Precondition: lookup_context represents a prior cache lookup that required + // validation. + // + // Update the headers of that cache entry to match response_headers. The cache + // entry's body and trailers (if any) will not be modified. + // + // This is called when an expired cache entry is successfully validated, to + // update the cache entry. + virtual void updateHeaders(LookupContextPtr&& lookup_context, + Http::HeaderMapPtr&& response_headers) PURE; + + // Returns statically known information about a cache. + virtual CacheInfo cacheInfo() const PURE; + + virtual ~HttpCache() = default; +}; + +// Factory interface for cache implementations to implement and register. +class HttpCacheFactory : public Config::TypedFactory { +public: + // name should be in reverse DNS format, though this is not enforced. + explicit HttpCacheFactory(std::string name) : name_(std::move(name)) {} + std::string name() const override { return name_; } + std::string category() const override { return "http_cache_factory"; } + + // Returns an HttpCache that will remain valid indefinitely (at least as long + // as the calling CacheFilter). + virtual HttpCache& + getCache(const envoy::config::filter::http::cache::v2::CacheConfig& config) PURE; + virtual ~HttpCacheFactory() = default; + +private: + const std::string name_; +}; +using HttpCacheFactoryPtr = std::unique_ptr; +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/http_cache_utils.cc b/source/extensions/filters/http/cache/http_cache_utils.cc new file mode 100644 index 0000000000000..6eb8dff24d777 --- /dev/null +++ b/source/extensions/filters/http/cache/http_cache_utils.cc @@ -0,0 +1,188 @@ +#include "extensions/filters/http/cache/http_cache_utils.h" + +#include +#include + +#include "absl/algorithm/container.h" +#include "absl/strings/ascii.h" +#include "absl/strings/numbers.h" +#include "absl/strings/strip.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +// True for characters defined as tchars by +// https://tools.ietf.org/html/rfc7230#section-3.2.6 +// +// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" +// / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +bool Utils::tchar(char c) { + switch (c) { + case '!': + case '#': + case '$': + case '%': + case '&': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + } + return absl::ascii_isalnum(c); +} + +// Removes an initial HTTP header field value token, as defined by +// https://tools.ietf.org/html/rfc7230#section-3.2.6. Returns true if an initial +// token was present. +// +// token = 1*tchar +bool Utils::eatToken(absl::string_view& s) { + const absl::string_view::iterator token_end = c_find_if_not(s, &tchar); + if (token_end == s.begin()) { + return false; + } + s.remove_prefix(token_end - s.begin()); + return true; +} + +// Removes an initial token or quoted-string (if present), as defined by +// https://tools.ietf.org/html/rfc7234#section-5.2. If a cache-control directive +// has an argument (as indicated by '='), it should be in this form. +// +// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE +// qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text +// obs-text = %x80-FF +// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) +// VCHAR = %x21-7E ; visible (printing) characters +// +// For example, the directive "my-extension=42" has an argument of "42", so an +// input of "public, my-extension=42, max-age=999" +void Utils::eatDirectiveArgument(absl::string_view& s) { + if (s.empty()) { + return; + } + if (s.front() == '"') { + // TODO(toddmgreer): handle \-escaped quotes + const size_t closing_quote = s.find('"', 1); + s.remove_prefix(closing_quote); + } else { + eatToken(s); + } +} + +// If s is non-null and begins with a decimal number ([0-9]+), removes it from +// the input and returns a SystemTime::duration representing that many seconds. +// If s is null or doesn't begin with digits, returns +// SystemTime::duration::zero(). If parsing overflows, returns +// SystemTime::duration::max(). +SystemTime::duration Utils::eatLeadingDuration(absl::string_view& s) { + const absl::string_view::iterator digits_end = c_find_if_not(s, &absl::ascii_isdigit); + const size_t digits_length = digits_end - s.begin(); + if (digits_length == 0) { + return SystemTime::duration::zero(); + } + const absl::string_view digits(s.begin(), digits_length); + s.remove_prefix(digits_length); + uint64_t num; + return absl::SimpleAtoi(digits, &num) ? std::chrono::seconds(num) : SystemTime::duration::max(); +} + +// Returns the effective max-age represented by cache-control. If the result is +// SystemTime::duration::zero(), or is less than the response's, the response +// should be validated. +// +// TODO(toddmgreer): Write a CacheControl class to fully parse the cache-control +// header value. Consider sharing with the gzip filter. +SystemTime::duration Utils::effectiveMaxAge(absl::string_view cache_control) { + // The grammar for This Cache-Control header value should be: + // Cache-Control = 1#cache-directive + // cache-directive = token [ "=" ( token / quoted-string ) ] + // token = 1*tchar + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" + // / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + // qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text + // obs-text = %x80-FF + // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + // VCHAR = %x21-7E ; visible (printing) characters + SystemTime::duration max_age = SystemTime::duration::zero(); + bool found_s_maxage = false; + while (!cache_control.empty()) { + // Each time through the loop, we eat one cache-directive. Each branch + // either returns or completely eats a cache-directive. + if (ConsumePrefix(&cache_control, "no-cache")) { + if (eatToken(cache_control)) { + // The token wasn't no-cache; it just started that way, so we must + // finish eating this cache-directive. + if (ConsumePrefix(&cache_control, "=")) { + eatDirectiveArgument(cache_control); + } + } else { + // Found a no-cache directive, so validation is required. + return SystemTime::duration::zero(); + } + } else if (ConsumePrefix(&cache_control, "s-maxage=")) { + max_age = eatLeadingDuration(cache_control); + found_s_maxage = true; + cache_control = StripLeadingAsciiWhitespace(cache_control); + if (!cache_control.empty() && cache_control[0] != ',') { + // Unexpected text at end of directive + return SystemTime::duration::zero(); + } + } else if (!found_s_maxage && ConsumePrefix(&cache_control, "max-age=")) { + max_age = eatLeadingDuration(cache_control); + if (!cache_control.empty() && cache_control[0] != ',') { + // Unexpected text at end of directive + return SystemTime::duration::zero(); + } + } else if (eatToken(cache_control)) { + // Unknown directive--ignore. + if (ConsumePrefix(&cache_control, "=")) { + eatDirectiveArgument(cache_control); + } + } else { + // This directive starts with illegal characters. Require validation. + return SystemTime::duration::zero(); + } + // Whichever branch we took should have consumed the entire cache-directive, + // so we just need to eat the delimiter and optional whitespace. + ConsumePrefix(&cache_control, ","); + cache_control = StripLeadingAsciiWhitespace(cache_control); + } + return max_age; +} + +SystemTime Utils::httpTime(const Http::HeaderEntry* header_entry) { + if (!header_entry) { + return {}; + } + absl::Time time; + const std::string input(header_entry->value().getStringView()); + + // Acceptable Date/Time Formats per + // https://tools.ietf.org/html/rfc7231#section-7.1.1.1 + // + // Sun, 06 Nov 1994 08:49:37 GMT ; IMF-fixdate + // Sunday, 06-Nov-94 08:49:37 GMT ; obsolete RFC 850 format + // Sun Nov 6 08:49:37 1994 ; ANSI C's asctime() format + static const auto& rfc7231_date_formats = *new std::array{ + "%a, %d %b %Y %H:%M:%S GMT", "%A, %d-%b-%y %H:%M:%S GMT", "%a %b %e %H:%M:%S %Y"}; + for (const std::string& format : rfc7231_date_formats) { + if (absl::ParseTime(format, input, &time, nullptr)) { + return ToChronoTime(time); + } + } + return {}; +} +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/http_cache_utils.h b/source/extensions/filters/http/cache/http_cache_utils.h new file mode 100644 index 0000000000000..d62599b8f5bb1 --- /dev/null +++ b/source/extensions/filters/http/cache/http_cache_utils.h @@ -0,0 +1,34 @@ +#pragma once + +#include "envoy/common/time.h" +#include "envoy/http/header_map.h" + +#include "absl/strings/string_view.h" +#include "absl/time/time.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +class Utils { +public: + // Parses and returns max-age or s-maxage (with s-maxage taking precedence), + // parsed into a SystemTime::Duration. Returns SystemTime::Duration::zero if + // neither is present, or there is a no-cache directive, or if max-age or + // s-maxage is malformed. + static SystemTime::duration effectiveMaxAge(absl::string_view cache_control); + + // Parses header_entry as an HTTP time. Returns SystemTime() if + // header_entry is null or malformed. + static SystemTime httpTime(const Http::HeaderEntry* header_entry); + +private: + static bool tchar(char c); + static bool eatToken(absl::string_view& s); + static void eatDirectiveArgument(absl::string_view& s); + static SystemTime::duration eatLeadingDuration(absl::string_view& s); +}; +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/key.proto b/source/extensions/filters/http/cache/key.proto new file mode 100644 index 0000000000000..e81a1b71cd752 --- /dev/null +++ b/source/extensions/filters/http/cache/key.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package Envoy.Extensions.HttpFilters.Cache; + +// Cache key for lookups and inserts. +message Key { + string cluster_name = 1; + string host = 2; + string path = 3; + string query = 4; + // True for http://, false for https://. + bool clear_http = 5; + // Cache implementations can store arbitrary content in these fields; never set by cache filter. + repeated bytes custom_fields = 6; + repeated int64 custom_ints = 7; +}; diff --git a/source/extensions/filters/http/cache/simple_http_cache.cc b/source/extensions/filters/http/cache/simple_http_cache.cc new file mode 100644 index 0000000000000..ce6717648b1c1 --- /dev/null +++ b/source/extensions/filters/http/cache/simple_http_cache.cc @@ -0,0 +1,150 @@ +#include "extensions/filters/http/cache/simple_http_cache.h" + +#include "envoy/registry/registry.h" + +#include "common/buffer/buffer_impl.h" +#include "common/common/lock_guard.h" +#include "common/http/header_map_impl.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace { + +class SimpleLookupContext : public LookupContext { +public: + explicit SimpleLookupContext(SimpleHttpCache& cache, LookupRequest&& request) + : cache_(cache), request_(std::move(request)) {} + + void getHeaders(LookupHeadersCallback&& cb) override { + auto entry = cache_.lookup(request_); + body_ = std::move(entry.body_); + cb(entry.response_headers_ + ? request_.makeLookupResult(std::move(entry.response_headers_), body_.size()) + : LookupResult{}); + } + + void getBody(const AdjustedByteRange& range, LookupBodyCallback&& cb) override { + RELEASE_ASSERT(range.end() <= body_.length(), "Attempt to read past end of body."); + cb(std::make_unique(&body_[range.begin()], range.length())); + } + + void getTrailers(LookupTrailersCallback&& cb) override { + // TODO(toddmgreer): Support trailers. + ASSERT(false, "We didn't say there were trailers."); + cb(nullptr); + } + + const LookupRequest& request() const { return request_; } + +private: + SimpleHttpCache& cache_; + const LookupRequest request_; + std::string body_; +}; + +class SimpleInsertContext : public InsertContext { +public: + SimpleInsertContext(LookupContext& lookup_context, SimpleHttpCache& cache) + : key_(dynamic_cast(lookup_context).request().key()), cache_(cache) {} + + void insertHeaders(const Http::HeaderMap& response_headers, bool end_stream) override { + ASSERT(!committed_); + response_headers_ = std::make_unique(response_headers); + if (end_stream) { + commit(); + } + } + + void insertBody(const Buffer::Instance& chunk, InsertCallback ready_for_next_chunk, + bool end_stream) override { + ASSERT(!committed_); + ASSERT(ready_for_next_chunk || end_stream); + + body_.add(chunk); + if (end_stream) { + commit(); + } else { + ready_for_next_chunk(true); + } + } + + void insertTrailers(const Http::HeaderMap&) override { + ASSERT(false); // TODO(toddmgreer): support trailers + } + +private: + void commit() { + committed_ = true; + cache_.insert(key_, std::move(response_headers_), body_.toString()); + } + + Key key_; + Http::HeaderMapImplPtr response_headers_; + SimpleHttpCache& cache_; + Buffer::OwnedImpl body_; + bool committed_ = false; +}; +} // namespace + +LookupContextPtr SimpleHttpCache::makeLookupContext(LookupRequest&& request) { + return std::make_unique(*this, std::move(request)); +} + +void SimpleHttpCache::updateHeaders(LookupContextPtr&& lookup_context, + Http::HeaderMapPtr&& response_headers) { + ASSERT(lookup_context); + ASSERT(response_headers); + // TODO(toddmgreer): Support updating headers. + ASSERT(false); +} + +SimpleHttpCache::Entry SimpleHttpCache::lookup(const LookupRequest& request) { + Thread::LockGuard lock(mutex_); + auto iter = map_.find(request.key()); + if (iter == map_.end()) { + return Entry{}; + } + ASSERT(iter->second.response_headers_); + return SimpleHttpCache::Entry{ + std::make_unique(*iter->second.response_headers_), iter->second.body_}; +} + +void SimpleHttpCache::insert(const Key& key, Http::HeaderMapPtr&& response_headers, + std::string&& body) { + Thread::LockGuard lock(mutex_); + map_[key] = SimpleHttpCache::Entry{std::move(response_headers), std::move(body)}; +} + +InsertContextPtr SimpleHttpCache::makeInsertContext(LookupContextPtr&& lookup_context) { + ASSERT(lookup_context != nullptr); + return std::make_unique(*lookup_context, *this); +} + +CacheInfo SimpleHttpCache::cacheInfo() const { + CacheInfo cache_info; + cache_info.name_ = "envoy.extensions.http.cache.simple"; + return cache_info; +} + +class SimpleHttpCacheFactory : public HttpCacheFactory { +public: + SimpleHttpCacheFactory() : HttpCacheFactory("envoy.extensions.http.cache.simple") {} + ProtobufTypes::MessagePtr createEmptyConfigProto() override { + return std::make_unique(); + } + HttpCache& getCache(const envoy::config::filter::http::cache::v2::CacheConfig&) override { + return cache_; + } + +private: + SimpleHttpCache cache_; +}; + +static Registry::RegisterFactory register_; + +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/cache/simple_http_cache.h b/source/extensions/filters/http/cache/simple_http_cache.h new file mode 100644 index 0000000000000..96b1d239c5e5d --- /dev/null +++ b/source/extensions/filters/http/cache/simple_http_cache.h @@ -0,0 +1,42 @@ +#pragma once + +#include "common/common/thread.h" +#include "common/protobuf/utility.h" + +#include "extensions/filters/http/cache/http_cache.h" + +#include "absl/base/thread_annotations.h" +#include "absl/container/flat_hash_map.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +// Example cache backend that never evicts. Not suitable for production use. +class SimpleHttpCache : public HttpCache { +private: + struct Entry { + Http::HeaderMapPtr response_headers_; + std::string body_; + }; + +public: + // HttpCache + LookupContextPtr makeLookupContext(LookupRequest&& request) override; + InsertContextPtr makeInsertContext(LookupContextPtr&& lookup_context) override; + void updateHeaders(LookupContextPtr&& lookup_context, + Http::HeaderMapPtr&& response_headers) override; + CacheInfo cacheInfo() const override; + + Entry lookup(const LookupRequest& request); + void insert(const Key& key, Http::HeaderMapPtr&& response_headers, std::string&& body); + + mutable Thread::MutexBasicLockable mutex_; + absl::flat_hash_map map_ GUARDED_BY(mutex_); +}; + +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/well_known_names.h b/source/extensions/filters/http/well_known_names.h index be3a0ece610d9..27521da27e573 100644 --- a/source/extensions/filters/http/well_known_names.h +++ b/source/extensions/filters/http/well_known_names.h @@ -14,6 +14,8 @@ class HttpFilterNameValues { public: // Buffer filter const std::string Buffer = "envoy.buffer"; + // Cache filter + const std::string Cache = "envoy.filters.http.cache"; // CORS filter const std::string Cors = "envoy.cors"; // CSRF filter diff --git a/test/extensions/filters/http/cache/BUILD b/test/extensions/filters/http/cache/BUILD new file mode 100644 index 0000000000000..a411835ace77e --- /dev/null +++ b/test/extensions/filters/http/cache/BUILD @@ -0,0 +1,86 @@ +licenses(["notice"]) # Apache 2 + +load("//bazel:envoy_build_system.bzl", "envoy_package") +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +envoy_package() + +envoy_extension_cc_test( + name = "http_cache_test", + srcs = ["http_cache_test.cc"], + extension_name = "envoy.filters.http.cache", + deps = [ + "//source/extensions/filters/http/cache:http_cache_lib", + "//test/mocks/http:http_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_name = "envoy.filters.http.cache", + deps = [ + "//source/extensions/filters/http/cache:config", + "//source/extensions/filters/http/cache:simple_http_cache_lib", + "//test/mocks/http:http_mocks", + "//test/mocks/server:server_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "http_cache_utils_test", + srcs = ["http_cache_utils_test.cc"], + extension_name = "envoy.filters.http.cache", + deps = [ + "//include/envoy/http:header_map_interface", + "//source/common/http:header_map_lib", + "//source/extensions/filters/http/cache:http_cache_utils_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "simple_http_cache_test", + srcs = ["simple_http_cache_test.cc"], + extension_name = "envoy.filters.http.cache", + deps = [ + "//source/extensions/filters/http/cache:simple_http_cache_lib", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "cache_filter_test", + srcs = ["cache_filter_test.cc"], + extension_name = "envoy.filters.http.cache", + deps = [ + "//source/extensions/filters/http/cache:cache_filter_lib", + "//source/extensions/filters/http/cache:simple_http_cache_lib", + "//test/mocks/server:server_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "cache_filter_integration_test", + srcs = [ + "cache_filter_integration_test.cc", + ], + extension_name = "envoy.filters.http.cache", + deps = [ + "//source/extensions/filters/http/cache:config", + "//source/extensions/filters/http/cache:http_cache_lib", + "//source/extensions/filters/http/cache:simple_http_cache_lib", + "//test/integration:http_protocol_integration_lib", + "//test/test_common:simulated_time_system_lib", + ], +) diff --git a/test/extensions/filters/http/cache/cache_filter_integration_test.cc b/test/extensions/filters/http/cache/cache_filter_integration_test.cc new file mode 100644 index 0000000000000..4444d51d492f1 --- /dev/null +++ b/test/extensions/filters/http/cache/cache_filter_integration_test.cc @@ -0,0 +1,83 @@ +#include "test/integration/http_protocol_integration.h" +#include "test/test_common/simulated_time_system.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { + +// TODO(toddmgreer): Expand integration test to include age header values, +// expiration, range headers, HEAD requests, trailers, config customizations, +// cache-control headers, and conditional header fields, as they are +// implemented. + +class CacheIntegrationTest : public Event::TestUsingSimulatedTime, + public HttpProtocolIntegrationTest { +public: + void TearDown() override { + cleanupUpstreamAndDownstream(); + HttpProtocolIntegrationTest::TearDown(); + } + + void initializeFilter(const std::string& config) { + config_helper_.addFilter(config); + initialize(); + codec_client_ = makeHttpConnection(makeClientConnection((lookupPort("http")))); + } + + const std::string default_config{R"EOF( + name: envoy.filters.http.cache + typed_config: + "@type": type.googleapis.com/envoy.config.filter.http.cache.v2.CacheConfig + name: envoy.extensions.http.cache.simple + )EOF"}; + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; +}; + +INSTANTIATE_TEST_SUITE_P(Protocols, CacheIntegrationTest, + testing::ValuesIn(HttpProtocolIntegrationTest::getProtocolTestParams()), + HttpProtocolIntegrationTest::protocolTestParamsToString); + +TEST_P(CacheIntegrationTest, MissInsertHit) { + // Set system time to cause Envoy's cached formatted time to match time on this thread. + simTime().setSystemTime(std::chrono::hours(1)); + initializeFilter(default_config); + + // Include test name and params in URL to make each test's requests unique. + const Http::TestHeaderMapImpl request_headers = { + {":method", "GET"}, + {":path", absl::StrCat("/", protocolTestParamsToString({GetParam(), 0}))}, + {":scheme", "http"}, + {":authority", "MissInsertHit"}}; + Http::TestHeaderMapImpl response_headers = {{":status", "200"}, + {"date", formatter_.now(simTime())}, + {"cache-control", "public,max-age=3600"}, + {"content-length", "42"}}; + + // Send first request, and get response from upstream. + { + IntegrationStreamDecoderPtr request = codec_client_->makeHeaderOnlyRequest(request_headers); + waitForNextUpstreamRequest(); + upstream_request_->encodeHeaders(response_headers, /*end_stream=*/false); + // send 42 'a's + upstream_request_->encodeData(42, true); + // Wait for the response to be read by the codec client. + request->waitForEndStream(); + EXPECT_TRUE(request->complete()); + EXPECT_THAT(request->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(request->headers().Age(), nullptr); + EXPECT_EQ(request->body(), std::string(42, 'a')); + } + + // Send second request, and get response from cache. + IntegrationStreamDecoderPtr request = codec_client_->makeHeaderOnlyRequest(request_headers); + request->waitForEndStream(); + EXPECT_TRUE(request->complete()); + EXPECT_THAT(request->headers(), IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(request->body(), std::string(42, 'a')); + EXPECT_NE(request->headers().Age(), nullptr); +} +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/cache_filter_test.cc b/test/extensions/filters/http/cache/cache_filter_test.cc new file mode 100644 index 0000000000000..18487c7905748 --- /dev/null +++ b/test/extensions/filters/http/cache/cache_filter_test.cc @@ -0,0 +1,120 @@ +#include "extensions/filters/http/cache/cache_filter.h" + +#include "test/mocks/server/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace { + +class CacheFilterTest : public ::testing::Test { +protected: + envoy::config::filter::http::cache::v2::CacheConfig config_; + NiceMock context_; + Event::SimulatedTimeSystem time_source_; + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; + Http::TestHeaderMapImpl request_headers_{ + {":path", "/"}, {":method", "GET"}, {"x-forwarded-proto", "https"}}; + Http::TestHeaderMapImpl response_headers_{{":status", "200"}, + {"date", formatter_.now(time_source_)}, + {"cache-control", "public,max-age=3600"}}; + NiceMock decoder_callbacks_; + NiceMock encoder_callbacks_; + + CacheFilterSharedPtr makeFilter() { + CacheFilterSharedPtr filter = + CacheFilter::make(config_, /*stats_prefix=*/"", context_.scope(), context_.timeSource()); + if (filter) { + filter->setDecoderFilterCallbacks(decoder_callbacks_); + filter->setEncoderFilterCallbacks(encoder_callbacks_); + } + return filter; + } +}; + +TEST_F(CacheFilterTest, ImmediateHitNoBody) { + request_headers_.setHost("ImmediateHitNoBody"); + ON_CALL(decoder_callbacks_, dispatcher()).WillByDefault(ReturnRef(context_.dispatcher_)); + ON_CALL(context_.dispatcher_, post(_)).WillByDefault(::testing::InvokeArgument<0>()); + + // Create filter for request 1 + config_.set_name("envoy.extensions.http.cache.simple"); + CacheFilterSharedPtr filter = makeFilter(); + ASSERT_TRUE(filter); + + // Decode request 1 header + EXPECT_CALL(decoder_callbacks_, continueDecoding); + EXPECT_EQ(filter->decodeHeaders(request_headers_, true), + Http::FilterHeadersStatus::StopIteration); + ::testing::Mock::VerifyAndClearExpectations(&decoder_callbacks_); + + // Encode response header + EXPECT_EQ(filter->encodeHeaders(response_headers_, true), Http::FilterHeadersStatus::Continue); + filter->onDestroy(); + + // Create filter for request 2 + filter = makeFilter(); + ASSERT_TRUE(filter); + + // Decode request 2 header + EXPECT_CALL(decoder_callbacks_, + encodeHeaders_(testing::AllOf(IsSupersetOfHeaders(response_headers_), + HeaderHasValueRef("age", "0")), + true)); + EXPECT_EQ(filter->decodeHeaders(request_headers_, true), + Http::FilterHeadersStatus::StopIteration); + ::testing::Mock::VerifyAndClearExpectations(&decoder_callbacks_); + filter->onDestroy(); +} + +TEST_F(CacheFilterTest, ImmediateHitBody) { + request_headers_.setHost("ImmediateHitBody"); + ON_CALL(decoder_callbacks_, dispatcher()).WillByDefault(ReturnRef(context_.dispatcher_)); + ON_CALL(context_.dispatcher_, post(_)).WillByDefault(::testing::InvokeArgument<0>()); + + // Create filter for request 1 + config_.set_name("envoy.extensions.http.cache.simple"); + CacheFilterSharedPtr filter = makeFilter(); + ASSERT_TRUE(filter); + + // Decode request 1 header + EXPECT_CALL(decoder_callbacks_, continueDecoding); + EXPECT_EQ(filter->decodeHeaders(request_headers_, true), + Http::FilterHeadersStatus::StopIteration); + ::testing::Mock::VerifyAndClearExpectations(&decoder_callbacks_); + + // Encode response header + const std::string body = "abc"; + Buffer::OwnedImpl buffer(body); + response_headers_.setContentLength(body.size()); + EXPECT_EQ(filter->encodeHeaders(response_headers_, false), Http::FilterHeadersStatus::Continue); + EXPECT_EQ(filter->encodeData(buffer, true), Http::FilterDataStatus::Continue); + filter->onDestroy(); + + // Create filter for request 2 + filter = makeFilter(); + ASSERT_TRUE(filter); + + // Decode request 2 header + EXPECT_CALL(decoder_callbacks_, + encodeHeaders_(testing::AllOf(IsSupersetOfHeaders(response_headers_), + HeaderHasValueRef("age", "0")), + false)); + EXPECT_CALL(decoder_callbacks_, + encodeData(testing::Property(&Buffer::Instance::toString, testing::Eq(body)), true)); + EXPECT_EQ(filter->decodeHeaders(request_headers_, true), + Http::FilterHeadersStatus::StopIteration); + ::testing::Mock::VerifyAndClearExpectations(&decoder_callbacks_); + filter->onDestroy(); +} + +} // namespace +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/config_test.cc b/test/extensions/filters/http/cache/config_test.cc new file mode 100644 index 0000000000000..c2b942c88408d --- /dev/null +++ b/test/extensions/filters/http/cache/config_test.cc @@ -0,0 +1,48 @@ +#include "extensions/filters/http/cache/config.h" +#include "extensions/filters/http/cache/simple_http_cache.h" + +#include "test/mocks/server/mocks.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace { + +class CacheFilterFactoryTest : public ::testing::Test { +protected: + envoy::config::filter::http::cache::v2::CacheConfig config_; + NiceMock context_; + CacheFilterFactory factory_; + Http::MockFilterChainFactoryCallbacks filter_callback_; +}; + +TEST_F(CacheFilterFactoryTest, Basic) { + config_.set_name("envoy.extensions.http.cache.simple"); + Http::FilterFactoryCb cb = factory_.createFilterFactoryFromProto(config_, "stats", context_); + Http::StreamFilterSharedPtr filter; + EXPECT_CALL(filter_callback_, addStreamFilter(_)).WillOnce(::testing::SaveArg<0>(&filter)); + cb(filter_callback_); + ASSERT(filter); + ASSERT(dynamic_cast(filter.get())); +} + +TEST_F(CacheFilterFactoryTest, NoName) { + Http::FilterFactoryCb cb = factory_.createFilterFactoryFromProto(config_, "stats", context_); + Http::StreamFilterSharedPtr filter; + EXPECT_THROW(cb(filter_callback_), EnvoyException); +} + +TEST_F(CacheFilterFactoryTest, UnregisteredName) { + config_.set_name("Wrong"); + Http::FilterFactoryCb cb = factory_.createFilterFactoryFromProto(config_, "stats", context_); + EXPECT_THROW(cb(filter_callback_), EnvoyException); +} +} // namespace +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/http_cache_test.cc b/test/extensions/filters/http/cache/http_cache_test.cc new file mode 100644 index 0000000000000..5eee698cf0941 --- /dev/null +++ b/test/extensions/filters/http/cache/http_cache_test.cc @@ -0,0 +1,256 @@ +#include "extensions/filters/http/cache/http_cache.h" + +#include "test/mocks/http/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +using testing::ContainerEq; +using testing::TestWithParam; +using testing::ValuesIn; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +TEST(RawByteRangeTest, IsSuffix) { + auto r = RawByteRange(UINT64_MAX, 4); + ASSERT_TRUE(r.isSuffix()); +} + +TEST(RawByteRangeTest, IsNotSuffix) { + auto r = RawByteRange(3, 4); + ASSERT_FALSE(r.isSuffix()); +} + +TEST(RawByteRangeTest, FirstBytePos) { + auto r = RawByteRange(3, 4); + ASSERT_EQ(3, r.firstBytePos()); +} + +TEST(RawByteRangeTest, LastBytePos) { + auto r = RawByteRange(3, 4); + ASSERT_EQ(4, r.lastBytePos()); +} + +TEST(RawByteRangeTest, SuffixLength) { + auto r = RawByteRange(UINT64_MAX, 4); + ASSERT_EQ(4, r.suffixLength()); +} + +TEST(AdjustedByteRangeTest, Length) { + auto a = AdjustedByteRange(3, 6); + ASSERT_EQ(3, a.length()); +} + +TEST(AdjustedByteRangeTest, TrimFront) { + auto a = AdjustedByteRange(3, 6); + a.trimFront(2); + ASSERT_EQ(5, a.begin()); +} + +TEST(AdjustedByteRangeTest, MaxLength) { + auto a = AdjustedByteRange(0, UINT64_MAX); + ASSERT_EQ(UINT64_MAX, a.length()); +} + +TEST(AdjustedByteRangeTest, MaxTrim) { + auto a = AdjustedByteRange(0, UINT64_MAX); + a.trimFront(UINT64_MAX); + ASSERT_EQ(0, a.length()); +} + +class LookupRequestTest : public testing::Test { +protected: + Event::SimulatedTimeSystem time_source_; + SystemTime current_time_ = time_source_.systemTime(); + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; + Http::TestHeaderMapImpl request_headers_{ + {":path", "/"}, {"x-forwarded-proto", "https"}, {":authority", "example.com"}}; +}; + +LookupResult makeLookupResult(const LookupRequest& lookup_request, + const Http::TestHeaderMapImpl& response_headers, + uint64_t content_length = 0) { + return lookup_request.makeLookupResult( + std::make_unique(response_headers), content_length); +} + +TEST_F(LookupRequestTest, MakeLookupResultNoBody) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers( + {{"date", formatter_.fromTime(current_time_)}, {"cache-control", "public, max-age=3600"}}); + const LookupResult lookup_response = makeLookupResult(lookup_request, response_headers); + ASSERT_EQ(CacheEntryStatus::Ok, lookup_response.cache_entry_status_); + ASSERT_TRUE(lookup_response.headers_); + EXPECT_THAT(*lookup_response.headers_, Http::IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(lookup_response.content_length_, 0); + EXPECT_TRUE(lookup_response.response_ranges_.empty()); + EXPECT_FALSE(lookup_response.has_trailers_); +} + +TEST_F(LookupRequestTest, MakeLookupResultBody) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers( + {{"date", formatter_.fromTime(current_time_)}, {"cache-control", "public, max-age=3600"}}); + const uint64_t content_length = 5; + const LookupResult lookup_response = + makeLookupResult(lookup_request, response_headers, content_length); + ASSERT_EQ(CacheEntryStatus::Ok, lookup_response.cache_entry_status_); + ASSERT_TRUE(lookup_response.headers_); + EXPECT_THAT(*lookup_response.headers_, Http::IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(lookup_response.content_length_, content_length); + EXPECT_TRUE(lookup_response.response_ranges_.empty()); + EXPECT_FALSE(lookup_response.has_trailers_); +} + +TEST_F(LookupRequestTest, MakeLookupResultNoDate) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers({{"cache-control", "public, max-age=3600"}}); + const LookupResult lookup_response = makeLookupResult(lookup_request, response_headers); + EXPECT_EQ(CacheEntryStatus::RequiresValidation, lookup_response.cache_entry_status_); + ASSERT_TRUE(lookup_response.headers_); + EXPECT_THAT(*lookup_response.headers_, Http::IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(lookup_response.content_length_, 0); + EXPECT_TRUE(lookup_response.response_ranges_.empty()); + EXPECT_FALSE(lookup_response.has_trailers_); +} + +TEST_F(LookupRequestTest, PrivateResponse) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers({{"age", "2"}, + {"cache-control", "private, max-age=3600"}, + {"date", formatter_.fromTime(current_time_)}}); + const LookupResult lookup_response = makeLookupResult(lookup_request, response_headers); + + // We must make sure at cache insertion time, private responses must not be + // inserted. However, if the insertion did happen, it would be served at the + // time of lookup. (Nothing should rely on this.) + ASSERT_EQ(CacheEntryStatus::Ok, lookup_response.cache_entry_status_); + ASSERT_TRUE(lookup_response.headers_); + EXPECT_THAT(*lookup_response.headers_, Http::IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(lookup_response.content_length_, 0); + EXPECT_TRUE(lookup_response.response_ranges_.empty()); + EXPECT_FALSE(lookup_response.has_trailers_); +} + +TEST_F(LookupRequestTest, Expired) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers( + {{"cache-control", "public, max-age=3600"}, {"date", "Thu, 01 Jan 2019 00:00:00 GMT"}}); + const LookupResult lookup_response = makeLookupResult(lookup_request, response_headers); + + EXPECT_EQ(CacheEntryStatus::RequiresValidation, lookup_response.cache_entry_status_); + ASSERT_TRUE(lookup_response.headers_); + EXPECT_THAT(*lookup_response.headers_, Http::IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(lookup_response.content_length_, 0); + EXPECT_TRUE(lookup_response.response_ranges_.empty()); + EXPECT_FALSE(lookup_response.has_trailers_); +} + +TEST_F(LookupRequestTest, ExpiredViaFallbackheader) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers( + {{"expires", formatter_.fromTime(current_time_ - std::chrono::seconds(5))}, + {"date", formatter_.fromTime(current_time_)}}); + const LookupResult lookup_response = makeLookupResult(lookup_request, response_headers); + + EXPECT_EQ(CacheEntryStatus::RequiresValidation, lookup_response.cache_entry_status_); +} + +TEST_F(LookupRequestTest, NotExpiredViaFallbackheader) { + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers( + {{"expires", formatter_.fromTime(current_time_ + std::chrono::seconds(5))}, + {"date", formatter_.fromTime(current_time_)}}); + const LookupResult lookup_response = makeLookupResult(lookup_request, response_headers); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_response.cache_entry_status_); +} + +TEST_F(LookupRequestTest, FullRange) { + request_headers_.addCopy("Range", "0-99"); + const LookupRequest lookup_request(request_headers_, current_time_); + const Http::TestHeaderMapImpl response_headers({{"date", formatter_.fromTime(current_time_)}, + {"cache-control", "public, max-age=3600"}, + {"content-length", "4"}}); + const uint64_t content_length = 4; + const LookupResult lookup_response = + makeLookupResult(lookup_request, response_headers, content_length); + ASSERT_EQ(CacheEntryStatus::Ok, lookup_response.cache_entry_status_); + ASSERT_TRUE(lookup_response.headers_); + EXPECT_THAT(*lookup_response.headers_, Http::IsSupersetOfHeaders(response_headers)); + EXPECT_EQ(lookup_response.content_length_, 4); + EXPECT_TRUE(lookup_response.response_ranges_.empty()); + EXPECT_FALSE(lookup_response.has_trailers_); +} + +struct AdjustByteRangeParams { + std::vector request; + std::vector result; + uint64_t content_length; +}; + +AdjustByteRangeParams satisfiable_ranges[] = + // request, result, content_length + { + // Various ways to request the full body. Full responses are signaled by empty result + // vectors. + {{{0, 3}}, {}, 4}, // byte-range-spec, exact + {{{UINT64_MAX, 4}}, {}, 4}, // suffix-byte-range-spec, exact + {{{0, 99}}, {}, 4}, // byte-range-spec, overlong + {{{0, UINT64_MAX}}, {}, 4}, // byte-range-spec, overlong + {{{UINT64_MAX, 5}}, {}, 4}, // suffix-byte-range-spec, overlong + {{{UINT64_MAX, UINT64_MAX - 1}}, {}, 4}, // suffix-byte-range-spec, overlong + {{{UINT64_MAX, UINT64_MAX}}, {}, 4}, // suffix-byte-range-spec, overlong + + // Single bytes + {{{0, 0}}, {{0, 1}}, 4}, + {{{1, 1}}, {{1, 2}}, 4}, + {{{3, 3}}, {{3, 4}}, 4}, + {{{UINT64_MAX, 1}}, {{3, 4}}, 4}, + + // Multiple bytes, starting in the middle + {{{1, 2}}, {{1, 3}}, 4}, // fully in the middle + {{{1, 3}}, {{1, 4}}, 4}, // to the end + {{{2, 21}}, {{2, 4}}, 4}, // overlong + {{{1, UINT64_MAX}}, {{1, 4}}, 4}}; // overlong +// TODO(toddmgreer): Before enabling support for multi-range requests, test it. + +class AdjustByteRangeTest : public TestWithParam {}; + +TEST_P(AdjustByteRangeTest, All) { + std::vector result; + ASSERT_TRUE(adjustByteRangeSet(result, GetParam().request, GetParam().content_length)); + EXPECT_THAT(result, ContainerEq(GetParam().result)); +} + +INSTANTIATE_TEST_SUITE_P(AdjustByteRangeTest, AdjustByteRangeTest, ValuesIn(satisfiable_ranges)); + +class AdjustByteRangeUnsatisfiableTest : public TestWithParam> {}; + +std::vector unsatisfiable_ranges[] = { + {{4, 5}}, + {{4, 9}}, + {{7, UINT64_MAX}}, + {{UINT64_MAX, 0}}, +}; + +TEST_P(AdjustByteRangeUnsatisfiableTest, All) { + std::vector result; + ASSERT_FALSE(adjustByteRangeSet(result, GetParam(), 3)); +} + +INSTANTIATE_TEST_SUITE_P(AdjustByteRangeUnsatisfiableTest, AdjustByteRangeUnsatisfiableTest, + ValuesIn(unsatisfiable_ranges)); + +TEST(AdjustByteRange, NoRangeRequest) { + std::vector result; + ASSERT_TRUE(adjustByteRangeSet(result, {}, 8)); + EXPECT_THAT(result, ContainerEq(std::vector{})); +} + +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/http_cache_utils_test.cc b/test/extensions/filters/http/cache/http_cache_utils_test.cc new file mode 100644 index 0000000000000..1fb3657d1308b --- /dev/null +++ b/test/extensions/filters/http/cache/http_cache_utils_test.cc @@ -0,0 +1,81 @@ +#include + +#include "common/http/header_map_impl.h" + +#include "extensions/filters/http/cache/http_cache_utils.h" + +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace { + +// TODO(toddmgreer): Add tests for eat* functions +// TODO(toddmgreer): More tests for httpTime, effectiveMaxAge + +class HttpTimeTest : public testing::TestWithParam {}; + +const char* const ok_times[] = { + "Sun, 06 Nov 1994 08:49:37 GMT", // IMF-fixdate + "Sunday, 06-Nov-94 08:49:37 GMT", // obsolete RFC 850 format + "Sun Nov 6 08:49:37 1994" // ANSI C's asctime() format +}; + +INSTANTIATE_TEST_SUITE_P(Ok, HttpTimeTest, testing::ValuesIn(ok_times)); + +TEST_P(HttpTimeTest, Ok) { + Http::TestHeaderMapImpl response_headers{{"date", GetParam()}}; + // Manually confirmed that 784111777 is 11/6/94, 8:46:37. + EXPECT_EQ(784111777, SystemTime::clock::to_time_t(Utils::httpTime(response_headers.Date()))); +} + +TEST(HttpTime, Null) { EXPECT_EQ(Utils::httpTime(nullptr), SystemTime()); } + +struct EffectiveMaxAgeParams { + absl::string_view cache_control; + int effective_max_age_secs; +}; + +EffectiveMaxAgeParams params[] = { + {"public, max-age=3600", 3600}, + {"public, max-age=-1", 0}, + {"max-age=20", 20}, + {"max-age=86400, public", 86400}, + {"public,max-age=\"0\"", 0}, + {"public,max-age=8", 8}, + {"public,max-age=3,no-cache", 0}, + {"s-maxage=0", 0}, + {"max-age=10,s-maxage=0", 0}, + {"s-maxage=10", 10}, + {"no-cache", 0}, + {"max-age=0", 0}, + {"no-cache", 0}, + {"public", 0}, + // TODO(toddmgreer): parse quoted forms + // {"max-age=20, s-maxage=\"25\"",25}, + // {"public,max-age=\"8\",foo=11",8}, + // {"public,max-age=\"8\",bar=\"11\"",8}, + // TODO(toddmgreer): parse public/private + // {"private,max-age=10",0} + // {"private",0}, + // {"private,s-maxage=8",0}, +}; + +class EffectiveMaxAgeTest : public testing::TestWithParam {}; + +INSTANTIATE_TEST_SUITE_P(EffectiveMaxAgeTest, EffectiveMaxAgeTest, testing::ValuesIn(params)); + +TEST_P(EffectiveMaxAgeTest, EffectiveMaxAgeTest) { + EXPECT_EQ(Utils::effectiveMaxAge(GetParam().cache_control), + std::chrono::seconds(GetParam().effective_max_age_secs)); +} + +} // namespace +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/cache/simple_http_cache_test.cc b/test/extensions/filters/http/cache/simple_http_cache_test.cc new file mode 100644 index 0000000000000..39b6c5a1c6bd9 --- /dev/null +++ b/test/extensions/filters/http/cache/simple_http_cache_test.cc @@ -0,0 +1,219 @@ +#include "envoy/http/header_map.h" +#include "envoy/registry/registry.h" + +#include "common/buffer/buffer_impl.h" + +#include "extensions/filters/http/cache/simple_http_cache.h" + +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace Cache { +namespace { + +const std::string EpochDate = "Thu, 01 Jan 1970 00:00:00 GMT"; + +class SimpleHttpCacheTest : public testing::Test { +protected: + SimpleHttpCacheTest() { + request_headers_.setMethod("GET"); + request_headers_.setHost("example.com"); + request_headers_.setForwardedProto("https"); + request_headers_.setCacheControl("max-age=3600"); + } + + // Performs a cache lookup. + LookupContextPtr lookup(absl::string_view request_path) { + LookupRequest request = makeLookupRequest(request_path); + LookupContextPtr context = cache_.makeLookupContext(std::move(request)); + context->getHeaders([this](LookupResult&& result) { lookup_result_ = std::move(result); }); + return context; + } + + // Inserts a value into the cache. + void insert(LookupContextPtr lookup, const Http::TestHeaderMapImpl& response_headers, + const absl::string_view response_body) { + InsertContextPtr inserter = cache_.makeInsertContext(move(lookup)); + inserter->insertHeaders(response_headers, false); + inserter->insertBody(Buffer::OwnedImpl(response_body), nullptr, true); + } + + void insert(absl::string_view request_path, const Http::TestHeaderMapImpl& response_headers, + const absl::string_view response_body) { + insert(lookup(request_path), response_headers, response_body); + } + + std::string getBody(LookupContext& context, uint64_t start, uint64_t end) { + AdjustedByteRange range(start, end); + std::string body; + context.getBody(range, [&body](Buffer::InstancePtr&& data) { + EXPECT_NE(data, nullptr); + if (data) { + body = data->toString(); + } + }); + return body; + } + + LookupRequest makeLookupRequest(absl::string_view request_path) { + request_headers_.setPath(request_path); + return LookupRequest(request_headers_, current_time_); + } + + AssertionResult expectLookupSuccessWithBody(LookupContext* lookup_context, + absl::string_view body) { + if (lookup_result_.cache_entry_status_ != CacheEntryStatus::Ok) { + return AssertionFailure() << "Expected: lookup_result_.cache_entry_status == " + "CacheEntryStatus::Ok\n Actual: " + << lookup_result_.cache_entry_status_; + } + if (!lookup_result_.headers_) { + return AssertionFailure() << "Expected nonnull lookup_result_.headers"; + } + if (!lookup_context) { + return AssertionFailure() << "Expected nonnull lookup_context"; + } + const std::string actual_body = getBody(*lookup_context, 0, body.size()); + if (body != actual_body) { + return AssertionFailure() << "Expected body == " << body << "\n Actual: " << actual_body; + } + return AssertionSuccess(); + } + + SimpleHttpCache cache_; + LookupResult lookup_result_; + Http::TestHeaderMapImpl request_headers_; + Event::SimulatedTimeSystem time_source_; + SystemTime current_time_ = time_source_.systemTime(); + DateFormatter formatter_{"%a, %d %b %Y %H:%M:%S GMT"}; +}; + +// Simple flow of putting in an item, getting it, deleting it. +TEST_F(SimpleHttpCacheTest, PutGet) { + const std::string RequestPath1("Name"); + LookupContextPtr name_lookup_context = lookup(RequestPath1); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + Http::TestHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"cache-control", "public,max-age=3600"}}; + + const std::string Body1("Value"); + insert(move(name_lookup_context), response_headers, Body1); + name_lookup_context = lookup(RequestPath1); + EXPECT_TRUE(expectLookupSuccessWithBody(name_lookup_context.get(), Body1)); + + const std::string& RequestPath2("Another Name"); + LookupContextPtr another_name_lookup_context = lookup(RequestPath2); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string NewBody1("NewValue"); + insert(move(name_lookup_context), response_headers, NewBody1); + EXPECT_TRUE(expectLookupSuccessWithBody(lookup(RequestPath1).get(), NewBody1)); +} + +TEST_F(SimpleHttpCacheTest, PrivateResponse) { + Http::TestHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"age", "2"}, + {"cache-control", "private,max-age=3600"}}; + const std::string request_path("Name"); + + LookupContextPtr name_lookup_context = lookup(request_path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + const std::string Body("Value"); + // We must make sure at cache insertion time, private responses must not be + // inserted. However, if the insertion did happen, it would be served at the + // time of lookup. + insert(move(name_lookup_context), response_headers, Body); + EXPECT_TRUE(expectLookupSuccessWithBody(lookup(request_path).get(), Body)); +} + +TEST_F(SimpleHttpCacheTest, Miss) { + LookupContextPtr name_lookup_context = lookup("Name"); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); +} + +TEST_F(SimpleHttpCacheTest, Fresh) { + const Http::TestHeaderMapImpl response_headers = {{"date", formatter_.fromTime(current_time_)}, + {"cache-control", "public, max-age=3600"}}; + // TODO(toddmgreer): Test with various date headers. + insert("/", response_headers, ""); + time_source_.sleep(std::chrono::seconds(3600)); + lookup("/"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); +} + +TEST_F(SimpleHttpCacheTest, Stale) { + const Http::TestHeaderMapImpl response_headers = {{"date", formatter_.fromTime(current_time_)}, + {"cache-control", "public, max-age=3600"}}; + // TODO(toddmgreer): Test with various date headers. + insert("/", response_headers, ""); + time_source_.sleep(std::chrono::seconds(3601)); + lookup("/"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); +} + +TEST_F(SimpleHttpCacheTest, RequestSmallMinFresh) { + request_headers_.setReferenceKey(Http::Headers::get().CacheControl, "min-fresh=1000"); + const std::string request_path("Name"); + LookupContextPtr name_lookup_context = lookup(request_path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + Http::TestHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"age", "6000"}, + {"cache-control", "public, max-age=9000"}}; + const std::string Body("Value"); + insert(move(name_lookup_context), response_headers, Body); + EXPECT_TRUE(expectLookupSuccessWithBody(lookup(request_path).get(), Body)); +} + +TEST_F(SimpleHttpCacheTest, ResponseStaleWithRequestLargeMaxStale) { + request_headers_.setReferenceKey(Http::Headers::get().CacheControl, "max-stale=9000"); + + const std::string request_path("Name"); + LookupContextPtr name_lookup_context = lookup(request_path); + EXPECT_EQ(CacheEntryStatus::Unusable, lookup_result_.cache_entry_status_); + + Http::TestHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"age", "7200"}, + {"cache-control", "public, max-age=3600"}}; + + const std::string Body("Value"); + insert(move(name_lookup_context), response_headers, Body); + EXPECT_TRUE(expectLookupSuccessWithBody(lookup(request_path).get(), Body)); +} + +TEST_F(SimpleHttpCacheTest, StreamingPut) { + Http::TestHeaderMapImpl response_headers{{"date", formatter_.fromTime(current_time_)}, + {"age", "2"}, + {"cache-control", "public, max-age=3600"}}; + InsertContextPtr inserter = cache_.makeInsertContext(lookup("request_path")); + inserter->insertHeaders(response_headers, false); + inserter->insertBody( + Buffer::OwnedImpl("Hello, "), [](bool ready) { EXPECT_TRUE(ready); }, false); + inserter->insertBody(Buffer::OwnedImpl("World!"), nullptr, true); + LookupContextPtr name_lookup_context = lookup("request_path"); + EXPECT_EQ(CacheEntryStatus::Ok, lookup_result_.cache_entry_status_); + EXPECT_NE(nullptr, lookup_result_.headers_); + ASSERT_EQ(13, lookup_result_.content_length_); + EXPECT_EQ("Hello, World!", getBody(*name_lookup_context, 0, 13)); +} + +TEST(Registration, GetFactory) { + envoy::config::filter::http::cache::v2::CacheConfig config; + HttpCacheFactory* factory = + Registry::FactoryRegistry::getFactory("envoy.extensions.http.cache.simple"); + ASSERT_NE(factory, nullptr); + EXPECT_EQ(factory->getCache(config).cacheInfo().name_, "envoy.extensions.http.cache.simple"); +} + +} // namespace +} // namespace Cache +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/tools/spelling_dictionary.txt b/tools/spelling_dictionary.txt index 746f36155a11d..12d330e002fcd 100644 --- a/tools/spelling_dictionary.txt +++ b/tools/spelling_dictionary.txt @@ -58,6 +58,7 @@ DFATAL DGRAM DLOG DNS +DQUOTE DRYs DS DST @@ -121,6 +122,7 @@ HCM HDS HMAC HPACK +HTAB HTML HTTP HTTPS @@ -139,6 +141,7 @@ IPV IPs IPv ITOA +InifinitePast Isode Iters JSON @@ -212,6 +215,7 @@ PostCBs PREBIND PRNG PROT +Prereq QUIC QoS RAII @@ -304,6 +308,7 @@ UTF UUID UUIDs VC +VCHAR VH VHDS VLOG @@ -317,6 +322,24 @@ WS Welford's Wi XDS +asctime +cacheability +dechunk +dechunked +fixdate +maxage +qdtext +satisfiable +tchar +tchars +unsatisfiable +decRefCount +getaddrinfo +sendto +ssize +upcasts +vip +xDSes XFCC XFF XML