diff --git a/CODEOWNERS b/CODEOWNERS index 9de37d0646ad7..6038872ce0cc6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -68,6 +68,8 @@ extensions/filters/common/original_src @snowp @klarose /*/extensions/common/aws @lavignes @mattklein123 # adaptive concurrency limit extension. /*/extensions/filters/http/adaptive_concurrency @tonya11en @mattklein123 +# admission control extension. +/*/extensions/filters/http/admission_control @tonya11en @mattklein123 # http inspector /*/extensions/filters/listener/http_inspector @yxue @PiotrSikora @lizan # attribute context diff --git a/api/BUILD b/api/BUILD index e803ebf19244a..3bbdd21a64faf 100644 --- a/api/BUILD +++ b/api/BUILD @@ -164,6 +164,7 @@ proto_library( "//envoy/extensions/compression/gzip/decompressor/v3:pkg", "//envoy/extensions/filters/common/fault/v3:pkg", "//envoy/extensions/filters/http/adaptive_concurrency/v3:pkg", + "//envoy/extensions/filters/http/admission_control/v3alpha:pkg", "//envoy/extensions/filters/http/aws_lambda/v3:pkg", "//envoy/extensions/filters/http/aws_request_signing/v3:pkg", "//envoy/extensions/filters/http/buffer/v3:pkg", diff --git a/api/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto b/api/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto index 3d2ef3e96d968..8dd851f4020a5 100644 --- a/api/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto +++ b/api/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto @@ -5,7 +5,6 @@ package envoy.extensions.filters.http.adaptive_concurrency.v3; import "envoy/config/core/v3/base.proto"; import "envoy/type/v3/percent.proto"; -import "google/api/annotations.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/wrappers.proto"; diff --git a/api/envoy/extensions/filters/http/admission_control/v3alpha/BUILD b/api/envoy/extensions/filters/http/admission_control/v3alpha/BUILD new file mode 100644 index 0000000000000..f139cce54af25 --- /dev/null +++ b/api/envoy/extensions/filters/http/admission_control/v3alpha/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/config/core/v3:pkg", + "//envoy/type/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/filters/http/admission_control/v3alpha/admission_control.proto b/api/envoy/extensions/filters/http/admission_control/v3alpha/admission_control.proto new file mode 100644 index 0000000000000..6f01c88885f4e --- /dev/null +++ b/api/envoy/extensions/filters/http/admission_control/v3alpha/admission_control.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.admission_control.v3alpha; + +import "envoy/config/core/v3/base.proto"; +import "envoy/type/v3/range.proto"; + +import "google/api/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/wrappers.proto"; +import "google/rpc/status.proto"; + +import "udpa/annotations/migrate.proto"; +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.admission_control.v3alpha"; +option java_outer_classname = "AdmissionControlProto"; +option java_multiple_files = true; +option (udpa.annotations.file_status).work_in_progress = true; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Admission Control] +// [#extension: envoy.filters.http.admission_control] + +message AdmissionControl { + // Default method of specifying what constitutes a successful request. All status codes that + // indicate a successful request must be explicitly specified if not relying on the default + // values. + message SuccessCriteria { + message HttpCriteria { + // Status code ranges that constitute a successful request. Configurable codes are in the + // range [100, 600). + repeated type.v3.Int32Range http_success_status = 1 + [(validate.rules).repeated = {min_items: 1}]; + } + + message GrpcCriteria { + // Status codes that constitute a successful request. + // Mappings can be found at: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md. + repeated uint32 grpc_success_status = 1 [(validate.rules).repeated = {min_items: 1}]; + } + + // If HTTP criteria are unspecified, all HTTP status codes below 500 are treated as successful + // responses. + // + // .. note:: + // + // The default HTTP codes considered successful by the admission controller are done so due + // to the unlikelihood that sending fewer requests would change their behavior (for example: + // redirects, unauthorized access, or bad requests won't be alleviated by sending less + // traffic). + HttpCriteria http_criteria = 1; + + // GRPC status codes to consider as request successes. If unspecified, defaults to: Ok, + // Cancelled, Unknown, InvalidArgument, NotFound, AlreadyExists, Unauthenticated, + // FailedPrecondition, OutOfRange, PermissionDenied, and Unimplemented. + // + // .. note:: + // + // The default gRPC codes that are considered successful by the admission controller are + // chosen because of the unlikelihood that sending fewer requests will change the behavior. + GrpcCriteria grpc_criteria = 2; + } + + // If set to false, the admission control filter will operate as a pass-through filter. If the + // message is unspecified, the filter will be enabled. + config.core.v3.RuntimeFeatureFlag enabled = 1; + + // Defines how a request is considered a success/failure. + oneof evaluation_criteria { + option (validate.required) = true; + + SuccessCriteria success_criteria = 2; + } + + // The sliding time window over which the success rate is calculated. The window is rounded to the + // nearest second. Defaults to 120s. + google.protobuf.Duration sampling_window = 3; + + // Rejection probability is defined by the formula:: + // + // max(0, (rq_count - aggression_coefficient * rq_success_count) / (rq_count + 1)) + // + // The coefficient dictates how aggressively the admission controller will throttle requests as + // the success rate drops. Lower values will cause throttling to kick in at higher success rates + // and result in more aggressive throttling. Any values less than 1.0, will be set to 1.0. If the + // message is unspecified, the coefficient is 2.0. + config.core.v3.RuntimeDouble aggression_coefficient = 4; +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index c26c4a8940939..796d8246a31e0 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -47,6 +47,7 @@ proto_library( "//envoy/extensions/compression/gzip/decompressor/v3:pkg", "//envoy/extensions/filters/common/fault/v3:pkg", "//envoy/extensions/filters/http/adaptive_concurrency/v3:pkg", + "//envoy/extensions/filters/http/admission_control/v3alpha:pkg", "//envoy/extensions/filters/http/aws_lambda/v3:pkg", "//envoy/extensions/filters/http/aws_request_signing/v3:pkg", "//envoy/extensions/filters/http/buffer/v3:pkg", diff --git a/docs/root/configuration/http/http_filters/http_filters.rst b/docs/root/configuration/http/http_filters/http_filters.rst index 911034fe13c69..97626448d2495 100644 --- a/docs/root/configuration/http/http_filters/http_filters.rst +++ b/docs/root/configuration/http/http_filters/http_filters.rst @@ -41,4 +41,5 @@ HTTP filters .. toctree:: :hidden: + ../../../api-v3/extensions/filters/http/admission_control/v3alpha/admission_control.proto ../../../api-v3/extensions/filters/http/cache/v3alpha/cache.proto diff --git a/generated_api_shadow/BUILD b/generated_api_shadow/BUILD index 15ac05d10cede..9ae658780070b 100644 --- a/generated_api_shadow/BUILD +++ b/generated_api_shadow/BUILD @@ -34,6 +34,7 @@ proto_library( "//envoy/config/filter/dubbo/router/v2alpha1:pkg", "//envoy/config/filter/fault/v2:pkg", "//envoy/config/filter/http/adaptive_concurrency/v2alpha:pkg", + "//envoy/config/filter/http/admission_control/v2alpha:pkg", "//envoy/config/filter/http/buffer/v2:pkg", "//envoy/config/filter/http/compressor/v2:pkg", "//envoy/config/filter/http/cors/v2:pkg", @@ -129,6 +130,7 @@ proto_library( "//envoy/extensions/common/tap/v3:pkg", "//envoy/extensions/filters/common/fault/v3:pkg", "//envoy/extensions/filters/http/adaptive_concurrency/v3:pkg", + "//envoy/extensions/filters/http/admission_control/v3alpha:pkg", "//envoy/extensions/filters/http/buffer/v3:pkg", "//envoy/extensions/filters/http/compressor/v3:pkg", "//envoy/extensions/filters/http/cors/v3:pkg", diff --git a/generated_api_shadow/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto b/generated_api_shadow/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto index 3d2ef3e96d968..8dd851f4020a5 100644 --- a/generated_api_shadow/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto +++ b/generated_api_shadow/envoy/extensions/filters/http/adaptive_concurrency/v3/adaptive_concurrency.proto @@ -5,7 +5,6 @@ package envoy.extensions.filters.http.adaptive_concurrency.v3; import "envoy/config/core/v3/base.proto"; import "envoy/type/v3/percent.proto"; -import "google/api/annotations.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/wrappers.proto"; diff --git a/generated_api_shadow/envoy/extensions/filters/http/admission_control/v3alpha/BUILD b/generated_api_shadow/envoy/extensions/filters/http/admission_control/v3alpha/BUILD new file mode 100644 index 0000000000000..f139cce54af25 --- /dev/null +++ b/generated_api_shadow/envoy/extensions/filters/http/admission_control/v3alpha/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/config/core/v3:pkg", + "//envoy/type/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/generated_api_shadow/envoy/extensions/filters/http/admission_control/v3alpha/admission_control.proto b/generated_api_shadow/envoy/extensions/filters/http/admission_control/v3alpha/admission_control.proto new file mode 100644 index 0000000000000..6f01c88885f4e --- /dev/null +++ b/generated_api_shadow/envoy/extensions/filters/http/admission_control/v3alpha/admission_control.proto @@ -0,0 +1,90 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.admission_control.v3alpha; + +import "envoy/config/core/v3/base.proto"; +import "envoy/type/v3/range.proto"; + +import "google/api/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/wrappers.proto"; +import "google/rpc/status.proto"; + +import "udpa/annotations/migrate.proto"; +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.admission_control.v3alpha"; +option java_outer_classname = "AdmissionControlProto"; +option java_multiple_files = true; +option (udpa.annotations.file_status).work_in_progress = true; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Admission Control] +// [#extension: envoy.filters.http.admission_control] + +message AdmissionControl { + // Default method of specifying what constitutes a successful request. All status codes that + // indicate a successful request must be explicitly specified if not relying on the default + // values. + message SuccessCriteria { + message HttpCriteria { + // Status code ranges that constitute a successful request. Configurable codes are in the + // range [100, 600). + repeated type.v3.Int32Range http_success_status = 1 + [(validate.rules).repeated = {min_items: 1}]; + } + + message GrpcCriteria { + // Status codes that constitute a successful request. + // Mappings can be found at: https://github.com/grpc/grpc/blob/master/doc/statuscodes.md. + repeated uint32 grpc_success_status = 1 [(validate.rules).repeated = {min_items: 1}]; + } + + // If HTTP criteria are unspecified, all HTTP status codes below 500 are treated as successful + // responses. + // + // .. note:: + // + // The default HTTP codes considered successful by the admission controller are done so due + // to the unlikelihood that sending fewer requests would change their behavior (for example: + // redirects, unauthorized access, or bad requests won't be alleviated by sending less + // traffic). + HttpCriteria http_criteria = 1; + + // GRPC status codes to consider as request successes. If unspecified, defaults to: Ok, + // Cancelled, Unknown, InvalidArgument, NotFound, AlreadyExists, Unauthenticated, + // FailedPrecondition, OutOfRange, PermissionDenied, and Unimplemented. + // + // .. note:: + // + // The default gRPC codes that are considered successful by the admission controller are + // chosen because of the unlikelihood that sending fewer requests will change the behavior. + GrpcCriteria grpc_criteria = 2; + } + + // If set to false, the admission control filter will operate as a pass-through filter. If the + // message is unspecified, the filter will be enabled. + config.core.v3.RuntimeFeatureFlag enabled = 1; + + // Defines how a request is considered a success/failure. + oneof evaluation_criteria { + option (validate.required) = true; + + SuccessCriteria success_criteria = 2; + } + + // The sliding time window over which the success rate is calculated. The window is rounded to the + // nearest second. Defaults to 120s. + google.protobuf.Duration sampling_window = 3; + + // Rejection probability is defined by the formula:: + // + // max(0, (rq_count - aggression_coefficient * rq_success_count) / (rq_count + 1)) + // + // The coefficient dictates how aggressively the admission controller will throttle requests as + // the success rate drops. Lower values will cause throttling to kick in at higher success rates + // and result in more aggressive throttling. Any values less than 1.0, will be set to 1.0. If the + // message is unspecified, the coefficient is 2.0. + config.core.v3.RuntimeDouble aggression_coefficient = 4; +} diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index 21009f9de918c..c6341c8cd8ddf 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -41,6 +41,9 @@ EXTENSIONS = { # "envoy.filters.http.adaptive_concurrency": "//source/extensions/filters/http/adaptive_concurrency:config", + # NOTE: The admission control filter does not have a proper filter + # implemented right now. We are just referencing the filter lib here. + "envoy.filters.http.admission_control": "//source/extensions/filters/http/admission_control:admission_control_filter_lib", "envoy.filters.http.aws_lambda": "//source/extensions/filters/http/aws_lambda:config", "envoy.filters.http.aws_request_signing": "//source/extensions/filters/http/aws_request_signing:config", "envoy.filters.http.buffer": "//source/extensions/filters/http/buffer:config", diff --git a/source/extensions/filters/http/admission_control/BUILD b/source/extensions/filters/http/admission_control/BUILD new file mode 100644 index 0000000000000..cb4a9975b09b6 --- /dev/null +++ b/source/extensions/filters/http/admission_control/BUILD @@ -0,0 +1,35 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_package", +) + +licenses(["notice"]) # Apache 2 + +# HTTP L7 filter that probabilistically rejects requests based on upstream success-rate. +# Public docs: docs/root/configuration/http_filters/admission_control.rst + +envoy_package() + +envoy_cc_extension( + name = "admission_control_filter_lib", + srcs = [ + "admission_control.cc", + ], + hdrs = [ + "admission_control.h", + "thread_local_controller.h", + ], + security_posture = "unknown", + deps = [ + "//include/envoy/http:filter_interface", + "//include/envoy/runtime:runtime_interface", + "//source/common/common:cleanup_lib", + "//source/common/http:codes_lib", + "//source/common/runtime:runtime_lib", + "//source/extensions/filters/http:well_known_names", + "//source/extensions/filters/http/admission_control/evaluators:response_evaluator_lib", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "@envoy_api//envoy/extensions/filters/http/admission_control/v3alpha:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/http/admission_control/admission_control.cc b/source/extensions/filters/http/admission_control/admission_control.cc new file mode 100644 index 0000000000000..7953b79c36f1c --- /dev/null +++ b/source/extensions/filters/http/admission_control/admission_control.cc @@ -0,0 +1,138 @@ +#include "extensions/filters/http/admission_control/admission_control.h" + +#include +#include +#include +#include + +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.h" +#include "envoy/grpc/status.h" +#include "envoy/http/codes.h" +#include "envoy/runtime/runtime.h" +#include "envoy/server/filter_config.h" + +#include "common/common/cleanup.h" +#include "common/common/enum_to_int.h" +#include "common/grpc/common.h" +#include "common/http/codes.h" +#include "common/http/utility.h" +#include "common/protobuf/utility.h" + +#include "extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h" +#include "extensions/filters/http/well_known_names.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { + +using GrpcStatus = Grpc::Status::GrpcStatus; + +static constexpr double defaultAggression = 2.0; + +AdmissionControlFilterConfig::AdmissionControlFilterConfig( + const AdmissionControlProto& proto_config, Runtime::Loader& runtime, TimeSource&, + Runtime::RandomGenerator& random, Stats::Scope& scope, ThreadLocal::SlotPtr&& tls, + std::shared_ptr response_evaluator) + : random_(random), scope_(scope), tls_(std::move(tls)), + admission_control_feature_(proto_config.enabled(), runtime), + aggression_( + proto_config.has_aggression_coefficient() + ? std::make_unique(proto_config.aggression_coefficient(), runtime) + : nullptr), + response_evaluator_(std::move(response_evaluator)) {} + +double AdmissionControlFilterConfig::aggression() const { + return std::max(1.0, aggression_ ? aggression_->value() : defaultAggression); +} + +AdmissionControlFilter::AdmissionControlFilter(AdmissionControlFilterConfigSharedPtr config, + const std::string& stats_prefix) + : config_(std::move(config)), stats_(generateStats(config_->scope(), stats_prefix)), + record_request_(true) {} + +Http::FilterHeadersStatus AdmissionControlFilter::decodeHeaders(Http::RequestHeaderMap&, bool) { + // TODO(tonya11en): Ensure we document the fact that healthchecks are ignored. + if (!config_->filterEnabled() || decoder_callbacks_->streamInfo().healthCheck()) { + // We must forego recording the success/failure of this request during encoding. + record_request_ = false; + return Http::FilterHeadersStatus::Continue; + } + + if (shouldRejectRequest()) { + decoder_callbacks_->sendLocalReply(Http::Code::ServiceUnavailable, "", nullptr, absl::nullopt, + "denied by admission control"); + stats_.rq_rejected_.inc(); + return Http::FilterHeadersStatus::StopIteration; + } + + return Http::FilterHeadersStatus::Continue; +} + +Http::FilterHeadersStatus AdmissionControlFilter::encodeHeaders(Http::ResponseHeaderMap& headers, + bool end_stream) { + // TODO(tonya11en): It's not possible for an HTTP filter to understand why a stream is reset, so + // we are not currently accounting for resets when recording requests. + + if (!record_request_) { + return Http::FilterHeadersStatus::Continue; + } + + bool successful_response = false; + if (Grpc::Common::isGrpcResponseHeaders(headers, end_stream)) { + absl::optional grpc_status = Grpc::Common::getGrpcStatus(headers); + + // If the GRPC status isn't found in the headers, it must be found in the trailers. + expect_grpc_status_in_trailer_ = !grpc_status.has_value(); + if (expect_grpc_status_in_trailer_) { + return Http::FilterHeadersStatus::Continue; + } + + const uint32_t status = enumToInt(grpc_status.value()); + successful_response = config_->responseEvaluator().isGrpcSuccess(status); + } else { + // HTTP response. + const uint64_t http_status = Http::Utility::getResponseStatus(headers); + successful_response = config_->responseEvaluator().isHttpSuccess(http_status); + } + + if (successful_response) { + recordSuccess(); + } else { + recordFailure(); + } + + return Http::FilterHeadersStatus::Continue; +} + +Http::FilterTrailersStatus +AdmissionControlFilter::encodeTrailers(Http::ResponseTrailerMap& trailers) { + if (expect_grpc_status_in_trailer_) { + absl::optional grpc_status = Grpc::Common::getGrpcStatus(trailers, false); + + if (grpc_status.has_value() && + config_->responseEvaluator().isGrpcSuccess(grpc_status.value())) { + recordSuccess(); + } else { + recordFailure(); + } + } + + return Http::FilterTrailersStatus::Continue; +} + +bool AdmissionControlFilter::shouldRejectRequest() const { + const double total = config_->getController().requestTotalCount(); + const double success = config_->getController().requestSuccessCount(); + const double probability = (total - config_->aggression() * success) / (total + 1); + + // Choosing an accuracy of 4 significant figures for the probability. + static constexpr uint64_t accuracy = 1e4; + auto r = config_->random().random(); + return (accuracy * std::max(probability, 0.0)) > (r % accuracy); +} + +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/admission_control/admission_control.h b/source/extensions/filters/http/admission_control/admission_control.h new file mode 100644 index 0000000000000..22edcf5393961 --- /dev/null +++ b/source/extensions/filters/http/admission_control/admission_control.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include + +#include "envoy/common/time.h" +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.h" +#include "envoy/http/codes.h" +#include "envoy/http/filter.h" +#include "envoy/runtime/runtime.h" +#include "envoy/server/filter_config.h" +#include "envoy/stats/scope.h" +#include "envoy/stats/stats_macros.h" + +#include "common/common/cleanup.h" +#include "common/grpc/common.h" +#include "common/grpc/status.h" +#include "common/http/codes.h" +#include "common/runtime/runtime_protos.h" + +#include "extensions/filters/http/admission_control/evaluators/response_evaluator.h" +#include "extensions/filters/http/admission_control/thread_local_controller.h" +#include "extensions/filters/http/common/pass_through_filter.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { + +/** + * All stats for the admission control filter. + */ +#define ALL_ADMISSION_CONTROL_STATS(COUNTER) COUNTER(rq_rejected) + +/** + * Wrapper struct for admission control filter stats. @see stats_macros.h + */ +struct AdmissionControlStats { + ALL_ADMISSION_CONTROL_STATS(GENERATE_COUNTER_STRUCT) +}; + +using AdmissionControlProto = + envoy::extensions::filters::http::admission_control::v3alpha::AdmissionControl; + +/** + * Configuration for the admission control filter. + */ +class AdmissionControlFilterConfig { +public: + AdmissionControlFilterConfig(const AdmissionControlProto& proto_config, Runtime::Loader& runtime, + TimeSource&, Runtime::RandomGenerator& random, Stats::Scope& scope, + ThreadLocal::SlotPtr&& tls, + std::shared_ptr response_evaluator); + virtual ~AdmissionControlFilterConfig() = default; + + virtual ThreadLocalController& getController() const { NOT_IMPLEMENTED_GCOVR_EXCL_LINE; } + + Runtime::RandomGenerator& random() const { return random_; } + bool filterEnabled() const { return admission_control_feature_.enabled(); } + Stats::Scope& scope() const { return scope_; } + double aggression() const; + ResponseEvaluator& responseEvaluator() const { return *response_evaluator_; } + +private: + Runtime::RandomGenerator& random_; + Stats::Scope& scope_; + const ThreadLocal::SlotPtr tls_; + Runtime::FeatureFlag admission_control_feature_; + std::unique_ptr aggression_; + std::shared_ptr response_evaluator_; +}; + +using AdmissionControlFilterConfigSharedPtr = std::shared_ptr; + +/** + * A filter that probabilistically rejects requests based on upstream success-rate. + */ +class AdmissionControlFilter : public Http::PassThroughFilter, + Logger::Loggable { +public: + AdmissionControlFilter(AdmissionControlFilterConfigSharedPtr config, + const std::string& stats_prefix); + + // Http::StreamDecoderFilter + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap&, bool) override; + + // Http::StreamEncoderFilter + Http::FilterHeadersStatus encodeHeaders(Http::ResponseHeaderMap& headers, + bool end_stream) override; + Http::FilterTrailersStatus encodeTrailers(Http::ResponseTrailerMap& trailers) override; + +private: + static AdmissionControlStats generateStats(Stats::Scope& scope, const std::string& prefix) { + return {ALL_ADMISSION_CONTROL_STATS(POOL_COUNTER_PREFIX(scope, prefix))}; + } + + bool shouldRejectRequest() const; + + void recordSuccess() { config_->getController().recordSuccess(); } + + void recordFailure() { config_->getController().recordFailure(); } + + const AdmissionControlFilterConfigSharedPtr config_; + AdmissionControlStats stats_; + bool expect_grpc_status_in_trailer_; + + // If false, the filter will forego recording a request success or failure during encoding. + bool record_request_; +}; + +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/admission_control/evaluators/BUILD b/source/extensions/filters/http/admission_control/evaluators/BUILD new file mode 100644 index 0000000000000..79910a264e7e5 --- /dev/null +++ b/source/extensions/filters/http/admission_control/evaluators/BUILD @@ -0,0 +1,26 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_library", + "envoy_package", +) + +licenses(["notice"]) # Apache 2 + +# HTTP L7 filter that probabilistically rejects requests based on upstream success-rate. + +envoy_package() + +envoy_cc_library( + name = "response_evaluator_lib", + srcs = ["success_criteria_evaluator.cc"], + hdrs = [ + "response_evaluator.h", + "success_criteria_evaluator.h", + ], + visibility = ["//visibility:public"], + deps = [ + "//include/envoy/grpc:status", + "//source/common/common:enum_to_int", + "@envoy_api//envoy/extensions/filters/http/admission_control/v3alpha:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/http/admission_control/evaluators/response_evaluator.h b/source/extensions/filters/http/admission_control/evaluators/response_evaluator.h new file mode 100644 index 0000000000000..9915014fdede2 --- /dev/null +++ b/source/extensions/filters/http/admission_control/evaluators/response_evaluator.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "envoy/common/pure.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { + +/** + * Determines of a request was successful based on response headers. + */ +class ResponseEvaluator { +public: + virtual ~ResponseEvaluator() = default; + + /** + * Returns true if the provided HTTP code constitutes a success. + */ + virtual bool isHttpSuccess(uint64_t code) const PURE; + + /** + * Returns true if the provided gRPC status counts constitutes a success. + */ + virtual bool isGrpcSuccess(uint32_t status) const PURE; +}; + +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.cc b/source/extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.cc new file mode 100644 index 0000000000000..6771bfba9a7b2 --- /dev/null +++ b/source/extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.cc @@ -0,0 +1,73 @@ +#include "extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h" + +#include + +#include "envoy/common/exception.h" +#include "envoy/grpc/status.h" + +#include "common/common/enum_to_int.h" +#include "common/common/fmt.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { + +SuccessCriteriaEvaluator::SuccessCriteriaEvaluator(const SuccessCriteria& success_criteria) { + // HTTP status. + if (success_criteria.has_http_criteria()) { + for (const auto& range : success_criteria.http_criteria().http_success_status()) { + if (!validHttpRange(range.start(), range.end())) { + throw EnvoyException( + fmt::format("invalid HTTP range: [{}, {})", range.start(), range.end())); + } + + const auto start = static_cast(range.start()); + const auto end = static_cast(range.end()); + http_success_fns_.emplace_back( + [start, end](uint64_t status) { return (start <= status) && (status < end); }); + } + } else { + // We default to all non-5xx codes as successes. + http_success_fns_.emplace_back([](uint64_t status) { return status < 500; }); + } + + // GRPC status. + if (success_criteria.has_grpc_criteria()) { + for (const auto& status : success_criteria.grpc_criteria().grpc_success_status()) { + if (status > 16) { + throw EnvoyException(fmt::format("invalid gRPC code {}", status)); + } + + grpc_success_codes_.emplace_back(status); + } + } else { + grpc_success_codes_ = { + enumToInt(Grpc::Status::WellKnownGrpcStatus::AlreadyExists), + enumToInt(Grpc::Status::WellKnownGrpcStatus::Canceled), + enumToInt(Grpc::Status::WellKnownGrpcStatus::FailedPrecondition), + enumToInt(Grpc::Status::WellKnownGrpcStatus::InvalidArgument), + enumToInt(Grpc::Status::WellKnownGrpcStatus::NotFound), + enumToInt(Grpc::Status::WellKnownGrpcStatus::Ok), + enumToInt(Grpc::Status::WellKnownGrpcStatus::OutOfRange), + enumToInt(Grpc::Status::WellKnownGrpcStatus::PermissionDenied), + enumToInt(Grpc::Status::WellKnownGrpcStatus::Unauthenticated), + enumToInt(Grpc::Status::WellKnownGrpcStatus::Unimplemented), + enumToInt(Grpc::Status::WellKnownGrpcStatus::Unknown), + }; + } +} + +bool SuccessCriteriaEvaluator::isGrpcSuccess(uint32_t status) const { + return std::count(grpc_success_codes_.begin(), grpc_success_codes_.end(), status) > 0; +} + +bool SuccessCriteriaEvaluator::isHttpSuccess(uint64_t code) const { + return std::any_of(http_success_fns_.begin(), http_success_fns_.end(), + [code](auto fn) { return fn(code); }); +} + +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h b/source/extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h new file mode 100644 index 0000000000000..511d54408f42e --- /dev/null +++ b/source/extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.h" +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.validate.h" + +#include "extensions/filters/http/admission_control/evaluators/response_evaluator.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { + +class SuccessCriteriaEvaluator : public ResponseEvaluator { +public: + using SuccessCriteria = envoy::extensions::filters::http::admission_control::v3alpha:: + AdmissionControl::SuccessCriteria; + SuccessCriteriaEvaluator(const SuccessCriteria& evaluation_criteria); + // ResponseEvaluator + bool isHttpSuccess(uint64_t code) const override; + bool isGrpcSuccess(uint32_t status) const override; + +private: + bool validHttpRange(const int32_t start, const int32_t end) const { + return start <= end && start < 600 && start >= 100 && end <= 600 && end >= 100; + } + + std::vector> http_success_fns_; + std::vector grpc_success_codes_; +}; + +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/admission_control/thread_local_controller.h b/source/extensions/filters/http/admission_control/thread_local_controller.h new file mode 100644 index 0000000000000..9b5096b805696 --- /dev/null +++ b/source/extensions/filters/http/admission_control/thread_local_controller.h @@ -0,0 +1,35 @@ +#pragma once + +#include "envoy/common/pure.h" +#include "envoy/common/time.h" +#include "envoy/http/codes.h" +#include "envoy/thread_local/thread_local.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { + +/* + * Thread-local admission controller interface. + */ +class ThreadLocalController { +public: + virtual ~ThreadLocalController() = default; + + // Record success/failure of a request and update the internal state of the controller to reflect + // this. + virtual void recordSuccess() PURE; + virtual void recordFailure() PURE; + + // Returns the current number of recorded requests. + virtual uint32_t requestTotalCount() PURE; + + // Returns the current number of recorded request successes. + virtual uint32_t requestSuccessCount() PURE; +}; + +} // namespace AdmissionControl +} // 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 c3971fa306789..2adb1681701fb 100644 --- a/source/extensions/filters/http/well_known_names.h +++ b/source/extensions/filters/http/well_known_names.h @@ -66,6 +66,8 @@ class HttpFilterNameValues { const std::string Tap = "envoy.filters.http.tap"; // Adaptive concurrency limit filter const std::string AdaptiveConcurrency = "envoy.filters.http.adaptive_concurrency"; + // Admission control filter + const std::string AdmissionControl = "envoy.filters.http.admission_control"; // Original Src Filter const std::string OriginalSrc = "envoy.filters.http.original_src"; // Dynamic forward proxy filter diff --git a/test/extensions/filters/http/admission_control/BUILD b/test/extensions/filters/http/admission_control/BUILD new file mode 100644 index 0000000000000..b161f26e16a14 --- /dev/null +++ b/test/extensions/filters/http/admission_control/BUILD @@ -0,0 +1,57 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_package", +) +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "admission_control_filter_test", + srcs = ["admission_control_filter_test.cc"], + extension_name = "envoy.filters.http.admission_control", + deps = [ + "//source/common/common:enum_to_int", + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/extensions/filters/http/admission_control:admission_control_filter_lib", + "//test/mocks/http:http_mocks", + "//test/mocks/server:server_mocks", + "//test/mocks/thread_local:thread_local_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + "@envoy_api//envoy/extensions/filters/http/admission_control/v3alpha:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_name = "envoy.filters.http.admission_control", + deps = [ + "//source/common/http:header_map_lib", + "//source/common/http:headers_lib", + "//source/extensions/filters/http/admission_control:admission_control_filter_lib", + "//test/mocks/http:http_mocks", + "//test/mocks/server:server_mocks", + "//test/mocks/thread_local:thread_local_mocks", + "//test/test_common:simulated_time_system_lib", + "//test/test_common:utility_lib", + "@envoy_api//envoy/extensions/filters/http/admission_control/v3alpha:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "success_criteria_evaluator_test", + srcs = ["success_criteria_evaluator_test.cc"], + extension_name = "envoy.filters.http.admission_control", + deps = [ + "//source/extensions/filters/http/admission_control:admission_control_filter_lib", + "@envoy_api//envoy/extensions/filters/http/admission_control/v3alpha:pkg_cc_proto", + ], +) diff --git a/test/extensions/filters/http/admission_control/admission_control_filter_test.cc b/test/extensions/filters/http/admission_control/admission_control_filter_test.cc new file mode 100644 index 0000000000000..5aaa6ba1658db --- /dev/null +++ b/test/extensions/filters/http/admission_control/admission_control_filter_test.cc @@ -0,0 +1,297 @@ +#include + +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.h" +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.validate.h" +#include "envoy/grpc/status.h" + +#include "common/common/enum_to_int.h" +#include "common/stats/isolated_store_impl.h" + +#include "extensions/filters/http/admission_control/admission_control.h" +#include "extensions/filters/http/admission_control/evaluators/response_evaluator.h" +#include "extensions/filters/http/admission_control/thread_local_controller.h" + +#include "test/mocks/runtime/mocks.h" +#include "test/mocks/server/mocks.h" +#include "test/mocks/thread_local/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using testing::NiceMock; +using testing::Return; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { +namespace { + +class MockThreadLocalController : public ThreadLocal::ThreadLocalObject, + public ThreadLocalController { +public: + MOCK_METHOD(uint32_t, requestTotalCount, ()); + MOCK_METHOD(uint32_t, requestSuccessCount, ()); + MOCK_METHOD(void, recordSuccess, ()); + MOCK_METHOD(void, recordFailure, ()); +}; + +class MockResponseEvaluator : public ResponseEvaluator { +public: + MOCK_METHOD(bool, isHttpSuccess, (uint64_t code), (const)); + MOCK_METHOD(bool, isGrpcSuccess, (uint32_t status), (const)); +}; + +class TestConfig : public AdmissionControlFilterConfig { +public: + TestConfig(const AdmissionControlProto& proto_config, Runtime::Loader& runtime, + TimeSource& time_source, Runtime::RandomGenerator& random, Stats::Scope& scope, + ThreadLocal::SlotPtr&& tls, MockThreadLocalController& controller, + std::shared_ptr evaluator) + : AdmissionControlFilterConfig(proto_config, runtime, time_source, random, scope, + std::move(tls), std::move(evaluator)), + controller_(controller) {} + ThreadLocalController& getController() const override { return controller_; } + +private: + MockThreadLocalController& controller_; +}; + +class AdmissionControlTest : public testing::Test { +public: + AdmissionControlTest() = default; + + std::shared_ptr makeConfig(const std::string& yaml) { + AdmissionControlProto proto; + TestUtility::loadFromYamlAndValidate(yaml, proto); + auto tls = context_.threadLocal().allocateSlot(); + evaluator_ = std::make_shared(); + + return std::make_shared(proto, runtime_, time_system_, random_, scope_, + std::move(tls), controller_, evaluator_); + } + + void setupFilter(std::shared_ptr config) { + filter_ = std::make_shared(config, "test_prefix."); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + } + + void sampleGrpcRequest(const Grpc::Status::WellKnownGrpcStatus status) { + Http::TestResponseHeaderMapImpl headers{{"content-type", "application/grpc"}, + {"grpc-status", std::to_string(enumToInt(status))}}; + filter_->encodeHeaders(headers, true); + } + + void sampleGrpcRequestTrailer(const Grpc::Status::WellKnownGrpcStatus status) { + Http::TestResponseHeaderMapImpl headers{{"content-type", "application/grpc"}, + {":status", "200"}}; + filter_->encodeHeaders(headers, false); + Http::TestResponseTrailerMapImpl trailers{{"grpc-message", "foo"}, + {"grpc-status", std::to_string(enumToInt(status))}}; + filter_->encodeTrailers(trailers); + } + + void sampleHttpRequest(const std::string& http_error_code) { + Http::TestResponseHeaderMapImpl headers{{":status", http_error_code}}; + filter_->encodeHeaders(headers, true); + } + +protected: + std::string stats_prefix_; + NiceMock runtime_; + NiceMock context_; + Stats::IsolatedStoreImpl scope_; + Event::SimulatedTimeSystem time_system_; + NiceMock random_; + std::shared_ptr filter_; + NiceMock decoder_callbacks_; + NiceMock controller_; + std::shared_ptr evaluator_; + const std::string default_yaml_{R"EOF( +enabled: + default_value: true + runtime_key: "foo.enabled" +sampling_window: 10s +aggression_coefficient: + default_value: 1.0 + runtime_key: "foo.aggression" +success_criteria: + http_criteria: + grpc_criteria: +)EOF"}; +}; + +// Ensure the filter can be disabled/enabled via runtime. +TEST_F(AdmissionControlTest, FilterRuntimeOverride) { + const std::string yaml = R"EOF( +enabled: + default_value: true + runtime_key: "foo.enabled" +sampling_window: 10s +aggression_coefficient: + default_value: 1.0 + runtime_key: "foo.aggression" +success_criteria: + http_criteria: + grpc_criteria: +)EOF"; + + auto config = makeConfig(yaml); + setupFilter(config); + + // "Disable" the filter via runtime. + EXPECT_CALL(runtime_.snapshot_, getBoolean("foo.enabled", true)).WillRepeatedly(Return(false)); + + // The filter is bypassed via runtime. + EXPECT_CALL(controller_, requestTotalCount()).Times(0); + EXPECT_CALL(controller_, requestSuccessCount()).Times(0); + + // We expect no rejections. + Http::RequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); +} + +// Ensure the filter disregards healthcheck traffic. +TEST_F(AdmissionControlTest, DisregardHealthChecks) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + StreamInfo::MockStreamInfo stream_info; + EXPECT_CALL(decoder_callbacks_, streamInfo()).WillOnce(testing::ReturnRef(stream_info)); + EXPECT_CALL(stream_info, healthCheck()).WillOnce(Return(true)); + + // We do not make admission decisions for health checks, so we expect no lookup of request success + // counts. + EXPECT_CALL(controller_, requestTotalCount()).Times(0); + EXPECT_CALL(controller_, requestSuccessCount()).Times(0); + + Http::TestRequestHeaderMapImpl request_headers; + Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, true)); +} + +// Validate simple HTTP failure case. +TEST_F(AdmissionControlTest, HttpFailureBehavior) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + // We expect rejection counter to increment upon failure. + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); + + EXPECT_CALL(controller_, requestTotalCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(controller_, requestSuccessCount()).WillRepeatedly(Return(0)); + EXPECT_CALL(*evaluator_, isHttpSuccess(500)).WillRepeatedly(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + sampleHttpRequest("500"); + + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 1, time_system_); +} + +// Validate simple HTTP success case. +TEST_F(AdmissionControlTest, HttpSuccessBehavior) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + // We expect rejection counter to NOT increment upon success. + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); + + EXPECT_CALL(controller_, requestTotalCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(controller_, requestSuccessCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(*evaluator_, isHttpSuccess(200)).WillRepeatedly(Return(true)); + + Http::TestRequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + sampleHttpRequest("200"); + + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); +} + +// Validate simple gRPC failure case. +TEST_F(AdmissionControlTest, GrpcFailureBehavior) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); + + EXPECT_CALL(controller_, requestTotalCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(controller_, requestSuccessCount()).WillRepeatedly(Return(0)); + EXPECT_CALL(*evaluator_, isGrpcSuccess(7)).WillRepeatedly(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + sampleGrpcRequest(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); + + // We expect rejection counter to increment upon failure. + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 1, time_system_); +} + +// Validate simple gRPC success case with status in the trailer. +TEST_F(AdmissionControlTest, GrpcSuccessBehaviorTrailer) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); + + EXPECT_CALL(controller_, requestTotalCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(controller_, requestSuccessCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(*evaluator_, isGrpcSuccess(0)).WillRepeatedly(Return(true)); + + Http::TestRequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + sampleGrpcRequestTrailer(Grpc::Status::WellKnownGrpcStatus::Ok); + + // We expect rejection counter to NOT increment upon success. + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); +} + +// Validate simple gRPC failure case with status in the trailer. +TEST_F(AdmissionControlTest, GrpcFailureBehaviorTrailer) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); + + EXPECT_CALL(controller_, requestTotalCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(controller_, requestSuccessCount()).WillRepeatedly(Return(0)); + EXPECT_CALL(*evaluator_, isGrpcSuccess(7)).WillRepeatedly(Return(false)); + + Http::TestRequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::StopIteration, + filter_->decodeHeaders(request_headers, true)); + sampleGrpcRequestTrailer(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); + + // We expect rejection counter to increment upon failure. + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 1, time_system_); +} + +// Validate simple gRPC success case. +TEST_F(AdmissionControlTest, GrpcSuccessBehavior) { + auto config = makeConfig(default_yaml_); + setupFilter(config); + + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); + + EXPECT_CALL(controller_, requestTotalCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(controller_, requestSuccessCount()).WillRepeatedly(Return(100)); + EXPECT_CALL(*evaluator_, isGrpcSuccess(0)).WillRepeatedly(Return(true)); + + Http::TestRequestHeaderMapImpl request_headers; + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + sampleGrpcRequest(Grpc::Status::WellKnownGrpcStatus::Ok); + + // We expect rejection counter to NOT increment upon success. + TestUtility::waitForCounterEq(scope_, "test_prefix.rq_rejected", 0, time_system_); +} + +} // namespace +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/admission_control/config_test.cc b/test/extensions/filters/http/admission_control/config_test.cc new file mode 100644 index 0000000000000..2201b3c36cb11 --- /dev/null +++ b/test/extensions/filters/http/admission_control/config_test.cc @@ -0,0 +1,114 @@ +#include + +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.h" +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.validate.h" + +#include "common/stats/isolated_store_impl.h" + +#include "extensions/filters/http/admission_control/admission_control.h" +#include "extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h" + +#include "test/mocks/runtime/mocks.h" +#include "test/mocks/server/mocks.h" +#include "test/mocks/thread_local/mocks.h" +#include "test/test_common/simulated_time_system.h" +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using testing::NiceMock; +using testing::Return; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { +namespace { + +class AdmissionControlConfigTest : public testing::Test { +public: + AdmissionControlConfigTest() = default; + + std::shared_ptr makeConfig(const std::string& yaml) { + AdmissionControlProto proto; + TestUtility::loadFromYamlAndValidate(yaml, proto); + auto tls = context_.threadLocal().allocateSlot(); + auto evaluator = std::make_unique(proto.success_criteria()); + return std::make_shared( + proto, runtime_, time_system_, random_, scope_, std::move(tls), std::move(evaluator)); + } + +protected: + NiceMock runtime_; + NiceMock context_; + Stats::IsolatedStoreImpl scope_; + Event::SimulatedTimeSystem time_system_; + NiceMock random_; +}; + +// Verify the configuration when all fields are set. +TEST_F(AdmissionControlConfigTest, BasicTestAllConfigured) { + const std::string yaml = R"EOF( +enabled: + default_value: false + runtime_key: "foo.enabled" +sampling_window: 1337s +aggression_coefficient: + default_value: 4.2 + runtime_key: "foo.aggression" +success_criteria: + http_criteria: + grpc_criteria: +)EOF"; + + auto config = makeConfig(yaml); + + EXPECT_FALSE(config->filterEnabled()); + EXPECT_EQ(4.2, config->aggression()); +} + +// Verify the config defaults when not specified. +TEST_F(AdmissionControlConfigTest, BasicTestMinimumConfigured) { + // Empty config. No fields are required. + AdmissionControlProto proto; + + const std::string yaml = R"EOF( +success_criteria: + http_criteria: + grpc_criteria: +)EOF"; + auto config = makeConfig(yaml); + + EXPECT_TRUE(config->filterEnabled()); + EXPECT_EQ(2.0, config->aggression()); +} + +// Ensure runtime fields are honored. +TEST_F(AdmissionControlConfigTest, VerifyRuntime) { + const std::string yaml = R"EOF( +enabled: + default_value: false + runtime_key: "foo.enabled" +sampling_window: 1337s +aggression_coefficient: + default_value: 4.2 + runtime_key: "foo.aggression" +success_criteria: + http_criteria: + grpc_criteria: +)EOF"; + + auto config = makeConfig(yaml); + + EXPECT_CALL(runtime_.snapshot_, getBoolean("foo.enabled", false)).WillOnce(Return(true)); + EXPECT_TRUE(config->filterEnabled()); + EXPECT_CALL(runtime_.snapshot_, getDouble("foo.aggression", 4.2)).WillOnce(Return(1.3)); + EXPECT_EQ(1.3, config->aggression()); +} + +} // namespace +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/admission_control/success_criteria_evaluator_test.cc b/test/extensions/filters/http/admission_control/success_criteria_evaluator_test.cc new file mode 100644 index 0000000000000..888497a1363e9 --- /dev/null +++ b/test/extensions/filters/http/admission_control/success_criteria_evaluator_test.cc @@ -0,0 +1,178 @@ +#include + +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.h" +#include "envoy/extensions/filters/http/admission_control/v3alpha/admission_control.pb.validate.h" + +#include "common/common/enum_to_int.h" + +#include "extensions/filters/http/admission_control/admission_control.h" +#include "extensions/filters/http/admission_control/evaluators/success_criteria_evaluator.h" + +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace AdmissionControl { +namespace { + +class SuccessCriteriaTest : public testing::Test { +public: + SuccessCriteriaTest() = default; + + void makeEvaluator(const std::string& yaml) { + AdmissionControlProto::SuccessCriteria proto; + TestUtility::loadFromYamlAndValidate(yaml, proto); + + evaluator_ = std::make_unique(proto); + } + + void expectHttpSuccess(int code) { EXPECT_TRUE(evaluator_->isHttpSuccess(code)); } + + void expectHttpFail(int code) { EXPECT_FALSE(evaluator_->isHttpSuccess(code)); } + + void expectGrpcSuccess(int code) { EXPECT_TRUE(evaluator_->isGrpcSuccess(code)); } + + void expectGrpcFail(int code) { EXPECT_FALSE(evaluator_->isGrpcSuccess(code)); } + + void verifyGrpcDefaultEval() { + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::AlreadyExists); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::Canceled); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::FailedPrecondition); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::InvalidArgument); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::NotFound); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::Ok); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::OutOfRange); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::PermissionDenied); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::Unauthenticated); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::Unimplemented); + expectGrpcSuccess(Grpc::Status::WellKnownGrpcStatus::Unknown); + + expectGrpcFail(enumToInt(Grpc::Status::WellKnownGrpcStatus::Aborted)); + expectGrpcFail(enumToInt(Grpc::Status::WellKnownGrpcStatus::DataLoss)); + expectGrpcFail(enumToInt(Grpc::Status::WellKnownGrpcStatus::DeadlineExceeded)); + expectGrpcFail(enumToInt(Grpc::Status::WellKnownGrpcStatus::Internal)); + expectGrpcFail(enumToInt(Grpc::Status::WellKnownGrpcStatus::ResourceExhausted)); + expectGrpcFail(enumToInt(Grpc::Status::WellKnownGrpcStatus::Unavailable)); + } + + void verifyHttpDefaultEval() { + for (int code = 200; code < 600; ++code) { + if (code < 500) { + expectHttpSuccess(code); + } else { + expectHttpFail(code); + } + } + } + +protected: + std::unique_ptr evaluator_; +}; + +// Ensure the HTTP code successful range configurations are honored. +TEST_F(SuccessCriteriaTest, HttpErrorCodes) { + const std::string yaml = R"EOF( +http_criteria: + http_success_status: + - start: 200 + end: 300 + - start: 400 + end: 500 +)EOF"; + + makeEvaluator(yaml); + + for (int code = 200; code < 600; ++code) { + if ((code < 300 && code >= 200) || (code < 500 && code >= 400)) { + expectHttpSuccess(code); + continue; + } + + expectHttpFail(code); + } + + verifyGrpcDefaultEval(); +} + +// Verify default success values of the evaluator. +TEST_F(SuccessCriteriaTest, DefaultBehaviorTest) { + const std::string yaml = R"EOF( +http_criteria: +grpc_criteria: +)EOF"; + + makeEvaluator(yaml); + verifyGrpcDefaultEval(); + verifyHttpDefaultEval(); +} + +// Check that GRPC error code configurations are honored. +TEST_F(SuccessCriteriaTest, GrpcErrorCodes) { + const std::string yaml = R"EOF( +grpc_criteria: + grpc_success_status: + - 7 + - 13 +)EOF"; + + makeEvaluator(yaml); + + using GrpcStatus = Grpc::Status::WellKnownGrpcStatus; + for (int code = GrpcStatus::Ok; code <= GrpcStatus::MaximumKnown; ++code) { + if (code == 7 || code == 13) { + expectGrpcSuccess(code); + } else { + expectGrpcFail(code); + } + } + + verifyHttpDefaultEval(); +} + +// Verify correct gRPC range validation. +TEST_F(SuccessCriteriaTest, GrpcRangeValidation) { + const std::string yaml = R"EOF( +grpc_criteria: + grpc_success_status: + - 17 +)EOF"; + EXPECT_THROW_WITH_REGEX(makeEvaluator(yaml), EnvoyException, "invalid gRPC code*"); +} + +// Verify correct HTTP range validation. +TEST_F(SuccessCriteriaTest, HttpRangeValidation) { + auto check_ranges = [this](std::string&& yaml) { + EXPECT_THROW_WITH_REGEX(makeEvaluator(yaml), EnvoyException, "invalid HTTP range*"); + }; + + check_ranges(R"EOF( +http_criteria: + http_success_status: + - start: 300 + end: 200 +)EOF"); + + check_ranges(R"EOF( +http_criteria: + http_success_status: + - start: 600 + end: 600 +)EOF"); + + check_ranges(R"EOF( +http_criteria: + http_success_status: + - start: 99 + end: 99 +)EOF"); +} + +} // namespace +} // namespace AdmissionControl +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/tools/spelling/spelling_dictionary.txt b/tools/spelling/spelling_dictionary.txt index de6d46a158758..cd6cd12b8899f 100644 --- a/tools/spelling/spelling_dictionary.txt +++ b/tools/spelling/spelling_dictionary.txt @@ -566,6 +566,7 @@ epoll errno etag etags +evaluator evbuffer evbuffers evconnlistener @@ -861,6 +862,7 @@ preorder prepend prepended prev +probabilistically proc profiler programmatically