diff --git a/.changelog/2844.txt b/.changelog/2844.txt new file mode 100644 index 0000000000..89ba684575 --- /dev/null +++ b/.changelog/2844.txt @@ -0,0 +1,3 @@ +```release-note:feature +helm: (Consul Enterprise) Adds rate limiting config to serviceDefaults CRD +``` diff --git a/acceptance/go.mod b/acceptance/go.mod index 1dd344a5c8..28d1491837 100644 --- a/acceptance/go.mod +++ b/acceptance/go.mod @@ -5,8 +5,8 @@ go 1.20 require ( github.com/gruntwork-io/terratest v0.31.2 github.com/hashicorp/consul-k8s/control-plane v0.0.0-20230609143603-198c4433d892 - github.com/hashicorp/consul/api v1.22.0-rc1 - github.com/hashicorp/consul/sdk v0.14.0-rc1 + github.com/hashicorp/consul/api v1.10.1-0.20230825164720-ecdcde430924 + github.com/hashicorp/consul/sdk v0.14.1 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hcp-sdk-go v0.50.0 diff --git a/acceptance/go.sum b/acceptance/go.sum index 7361efbbb6..b264e243c9 100644 --- a/acceptance/go.sum +++ b/acceptance/go.sum @@ -399,10 +399,10 @@ github.com/gruntwork-io/terratest v0.31.2 h1:xvYHA80MUq5kx670dM18HInewOrrQrAN+Xb github.com/gruntwork-io/terratest v0.31.2/go.mod h1:EEgJie28gX/4AD71IFqgMj6e99KP5mi81hEtzmDjxTo= github.com/hashicorp/consul-k8s/control-plane v0.0.0-20230609143603-198c4433d892 h1:4iI0ztWbVPTSDax+m1/XDs4jIRorxY4kSMyuM0fX+Dc= github.com/hashicorp/consul-k8s/control-plane v0.0.0-20230609143603-198c4433d892/go.mod h1:iZ8BJGSnY52wnxJTo2VIfGX63CPjqiNzbuqdOtJCKnI= -github.com/hashicorp/consul/api v1.22.0-rc1 h1:ePmGqndeMgaI38KUbSA/CqTzeEAIogXyWnfNJzglo70= -github.com/hashicorp/consul/api v1.22.0-rc1/go.mod h1:wtduXtbAqSGtBdi3tyA5SSAYGAG51rBejV9SEUBciMY= -github.com/hashicorp/consul/sdk v0.14.0-rc1 h1:PuETOfN0uxl28i0Pq6rK7TBCrIl7psMbL0YTSje4KvM= -github.com/hashicorp/consul/sdk v0.14.0-rc1/go.mod h1:gHYeuDa0+0qRAD6Wwr6yznMBvBwHKoxSBoW5l73+saE= +github.com/hashicorp/consul/api v1.10.1-0.20230825164720-ecdcde430924 h1:NPhzdwDho2r8pQv31oeGLlco7fnJ1i0WLYjtSXqWEck= +github.com/hashicorp/consul/api v1.10.1-0.20230825164720-ecdcde430924/go.mod h1:NZJGRFYruc/80wYowkPFCp1LbGmJC9L8izrwfyVx/Wg= +github.com/hashicorp/consul/sdk v0.14.1 h1:ZiwE2bKb+zro68sWzZ1SgHF3kRMBZ94TwOCFRF4ylPs= +github.com/hashicorp/consul/sdk v0.14.1/go.mod h1:vFt03juSzocLRFo59NkeQHHmQa6+g7oU0pfzdI1mUhg= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/acceptance/tests/config-entries/config_entries_test.go b/acceptance/tests/config-entries/config_entries_test.go index a36e9baaf5..81b0a75ff4 100644 --- a/acceptance/tests/config-entries/config_entries_test.go +++ b/acceptance/tests/config-entries/config_entries_test.go @@ -9,6 +9,11 @@ import ( "testing" "time" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/hashicorp/go-uuid" + "github.com/stretchr/testify/require" + "github.com/hashicorp/consul-k8s/acceptance/framework/config" "github.com/hashicorp/consul-k8s/acceptance/framework/consul" "github.com/hashicorp/consul-k8s/acceptance/framework/environment" @@ -16,10 +21,6 @@ import ( "github.com/hashicorp/consul-k8s/acceptance/framework/k8s" "github.com/hashicorp/consul-k8s/acceptance/framework/logger" "github.com/hashicorp/consul-k8s/acceptance/framework/vault" - "github.com/hashicorp/consul/api" - "github.com/hashicorp/consul/sdk/testutil/retry" - "github.com/hashicorp/go-uuid" - "github.com/stretchr/testify/require" ) const ( @@ -104,6 +105,7 @@ func TestController(t *testing.T) { svcDefaultEntry, ok := entry.(*api.ServiceConfigEntry) require.True(r, ok, "could not cast to ServiceConfigEntry") require.Equal(r, "http", svcDefaultEntry.Protocol) + require.Equal(r, 1234, svcDefaultEntry.RateLimits.InstanceLevel.RequestsPerSecond) // service-resolver entry, _, err = consulClient.ConfigEntries().Get(api.ServiceResolver, "resolver", nil) @@ -232,8 +234,6 @@ func TestController(t *testing.T) { require.Equal(r, 100.0, rateLimitIPConfigEntry.KV.WriteRate) require.Equal(r, 100.0, rateLimitIPConfigEntry.Tenancy.ReadRate) require.Equal(r, 100.0, rateLimitIPConfigEntry.Tenancy.WriteRate) - //require.Equal(r, 100.0, rateLimitIPConfigEntry.PreparedQuery.ReadRate) - //require.Equal(r, 100.0, rateLimitIPConfigEntry.PreparedQuery.WriteRate) require.Equal(r, 100.0, rateLimitIPConfigEntry.Session.ReadRate) require.Equal(r, 100.0, rateLimitIPConfigEntry.Session.WriteRate) require.Equal(r, 100.0, rateLimitIPConfigEntry.Txn.ReadRate) diff --git a/acceptance/tests/fixtures/bases/crds-oss/servicedefaults.yaml b/acceptance/tests/fixtures/bases/crds-oss/servicedefaults.yaml index cd9c35fa39..d0d1fe73bb 100644 --- a/acceptance/tests/fixtures/bases/crds-oss/servicedefaults.yaml +++ b/acceptance/tests/fixtures/bases/crds-oss/servicedefaults.yaml @@ -31,6 +31,18 @@ spec: interval: 10s maxFailures: 2 balanceInboundConnections: "exact_balance" + rateLimits: + instanceLevel: + requestsPerSecond: 1234 + requestsMaxBurst: 2345 + routes: + - pathExact: "/exact" + requestsPerSecond: 222 + requestsMaxBurst: 333 + - pathPrefix: "/prefix" + requestsPerSecond: 444 + - pathRegex: "/regex" + requestsPerSecond: 555 envoyExtensions: - name: builtin/aws/lambda required: false diff --git a/charts/consul/templates/crd-servicedefaults.yaml b/charts/consul/templates/crd-servicedefaults.yaml index 9e6c304bec..9fe23d160f 100644 --- a/charts/consul/templates/crd-servicedefaults.yaml +++ b/charts/consul/templates/crd-servicedefaults.yaml @@ -192,6 +192,69 @@ spec: unlock usage of the service-splitter and service-router config entries for a service. type: string + rateLimits: + description: RateLimits is rate limiting configuration that is applied + to inbound traffic for a service. Rate limiting is a Consul enterprise + feature. + properties: + instanceLevel: + description: InstanceLevel represents rate limit configuration + that is applied per service instance. + properties: + requestsMaxBurst: + description: "RequestsMaxBurst is the maximum number of requests + that can be sent in a burst. Should be equal to or greater + than RequestsPerSecond. If unset, defaults to RequestsPerSecond. + \n Internally, this is the maximum size of the token bucket + used for rate limiting." + type: integer + requestsPerSecond: + description: "RequestsPerSecond is the average number of requests + per second that can be made without being throttled. This + field is required if RequestsMaxBurst is set. The allowed + number of requests may exceed RequestsPerSecond up to the + value specified in RequestsMaxBurst. \n Internally, this + is the refill rate of the token bucket used for rate limiting." + type: integer + routes: + description: Routes is a list of rate limits applied to specific + routes. For a given request, the first matching route will + be applied, if any. Overrides any top-level configuration. + items: + properties: + pathExact: + description: Exact path to match. Exactly one of PathExact, + PathPrefix, or PathRegex must be specified. + type: string + pathPrefix: + description: Prefix to match. Exactly one of PathExact, + PathPrefix, or PathRegex must be specified. + type: string + pathRegex: + description: Regex to match. Exactly one of PathExact, + PathPrefix, or PathRegex must be specified. + type: string + requestsMaxBurst: + description: RequestsMaxBurst is the maximum number + of requests that can be sent in a burst. Should be + equal to or greater than RequestsPerSecond. If unset, + defaults to RequestsPerSecond. Internally, this is + the maximum size of the token bucket used for rate + limiting. + type: integer + requestsPerSecond: + description: RequestsPerSecond is the average number + of requests per second that can be made without being + throttled. This field is required if RequestsMaxBurst + is set. The allowed number of requests may exceed + RequestsPerSecond up to the value specified in RequestsMaxBurst. + Internally, this is the refill rate of the token bucket + used for rate limiting. + type: integer + type: object + type: array + type: object + type: object transparentProxy: description: 'TransparentProxy controls configuration specific to proxies in transparent mode. Note: This cannot be set using the diff --git a/control-plane/api/v1alpha1/servicedefaults_types.go b/control-plane/api/v1alpha1/servicedefaults_types.go index 2896475f75..fd764b32b2 100644 --- a/control-plane/api/v1alpha1/servicedefaults_types.go +++ b/control-plane/api/v1alpha1/servicedefaults_types.go @@ -18,8 +18,9 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" - "github.com/hashicorp/consul-k8s/control-plane/api/common" capi "github.com/hashicorp/consul/api" + + "github.com/hashicorp/consul-k8s/control-plane/api/common" ) const ( @@ -115,6 +116,9 @@ type ServiceDefaultsSpec struct { // proxy threads. The only supported value is exact_balance. By default, no connection balancing is used. // Refer to the Envoy Connection Balance config for details. BalanceInboundConnections string `json:"balanceInboundConnections,omitempty"` + // RateLimits is rate limiting configuration that is applied to + // inbound traffic for a service. Rate limiting is a Consul enterprise feature. + RateLimits *RateLimits `json:"rateLimits,omitempty"` // EnvoyExtensions are a list of extensions to modify Envoy proxy configuration. EnvoyExtensions EnvoyExtensions `json:"envoyExtensions,omitempty"` } @@ -216,6 +220,150 @@ type ServiceDefaultsDestination struct { Port uint32 `json:"port,omitempty"` } +// RateLimits is rate limiting configuration that is applied to +// inbound traffic for a service. +// Rate limiting is a Consul Enterprise feature. +type RateLimits struct { + // InstanceLevel represents rate limit configuration + // that is applied per service instance. + InstanceLevel InstanceLevelRateLimits `json:"instanceLevel,omitempty"` +} + +func (rl *RateLimits) toConsul() *capi.RateLimits { + if rl == nil { + return nil + } + routes := make([]capi.InstanceLevelRouteRateLimits, len(rl.InstanceLevel.Routes)) + for i, r := range rl.InstanceLevel.Routes { + routes[i] = capi.InstanceLevelRouteRateLimits{ + PathExact: r.PathExact, + PathPrefix: r.PathPrefix, + PathRegex: r.PathRegex, + RequestsPerSecond: r.RequestsPerSecond, + RequestsMaxBurst: r.RequestsMaxBurst, + } + } + return &capi.RateLimits{ + InstanceLevel: capi.InstanceLevelRateLimits{ + RequestsPerSecond: rl.InstanceLevel.RequestsPerSecond, + RequestsMaxBurst: rl.InstanceLevel.RequestsMaxBurst, + Routes: routes, + }, + } +} + +func (rl *RateLimits) validate(path *field.Path) field.ErrorList { + if rl == nil { + return nil + } + + return rl.InstanceLevel.validate(path.Child("instanceLevel")) +} + +type InstanceLevelRateLimits struct { + // RequestsPerSecond is the average number of requests per second that can be + // made without being throttled. This field is required if RequestsMaxBurst + // is set. The allowed number of requests may exceed RequestsPerSecond up to + // the value specified in RequestsMaxBurst. + // + // Internally, this is the refill rate of the token bucket used for rate limiting. + RequestsPerSecond int `json:"requestsPerSecond,omitempty"` + + // RequestsMaxBurst is the maximum number of requests that can be sent + // in a burst. Should be equal to or greater than RequestsPerSecond. + // If unset, defaults to RequestsPerSecond. + // + // Internally, this is the maximum size of the token bucket used for rate limiting. + RequestsMaxBurst int `json:"requestsMaxBurst,omitempty"` + + // Routes is a list of rate limits applied to specific routes. + // For a given request, the first matching route will be applied, if any. + // Overrides any top-level configuration. + Routes []InstanceLevelRouteRateLimits `json:"routes,omitempty"` +} + +func (irl InstanceLevelRateLimits) validate(path *field.Path) field.ErrorList { + var allErrs field.ErrorList + + // Track if RequestsPerSecond is set in at least one place in the config + isRateLimitSet := irl.RequestsPerSecond > 0 + + // Top-level RequestsPerSecond can be 0 (unset) or a positive number. + if irl.RequestsPerSecond < 0 { + allErrs = append(allErrs, + field.Invalid(path.Child("requestsPerSecond"), + irl.RequestsPerSecond, + "RequestsPerSecond must be positive")) + } + + if irl.RequestsPerSecond == 0 && irl.RequestsMaxBurst > 0 { + allErrs = append(allErrs, + field.Invalid(path.Child("requestsPerSecond"), + irl.RequestsPerSecond, + "RequestsPerSecond must be greater than 0 if RequestsMaxBurst is set")) + } + + if irl.RequestsMaxBurst < 0 { + allErrs = append(allErrs, + field.Invalid(path.Child("requestsMaxBurst"), + irl.RequestsMaxBurst, + "RequestsMaxBurst must be positive")) + } + + for i, route := range irl.Routes { + if exact, prefix, regex := route.PathExact != "", route.PathPrefix != "", route.PathRegex != ""; (!exact && !prefix && !regex) || + (exact && prefix) || (exact && regex) || (prefix && regex) { + allErrs = append(allErrs, field.Required( + path.Child("routes").Index(i), + "Route must define exactly one of PathExact, PathPrefix, or PathRegex")) + } + + isRateLimitSet = isRateLimitSet || route.RequestsPerSecond > 0 + + // Unlike top-level RequestsPerSecond, any route MUST have a RequestsPerSecond defined. + if route.RequestsPerSecond <= 0 { + allErrs = append(allErrs, field.Invalid( + path.Child("routes").Index(i).Child("requestsPerSecond"), + route.RequestsPerSecond, "RequestsPerSecond must be greater than 0")) + } + + if route.RequestsMaxBurst < 0 { + allErrs = append(allErrs, field.Invalid( + path.Child("routes").Index(i).Child("requestsMaxBurst"), + route.RequestsMaxBurst, "RequestsMaxBurst must be positive")) + } + } + + if !isRateLimitSet { + allErrs = append(allErrs, field.Invalid( + path.Child("requestsPerSecond"), + irl.RequestsPerSecond, "At least one of top-level or route-level RequestsPerSecond must be set")) + } + return allErrs +} + +type InstanceLevelRouteRateLimits struct { + // Exact path to match. Exactly one of PathExact, PathPrefix, or PathRegex must be specified. + PathExact string `json:"pathExact,omitempty"` + // Prefix to match. Exactly one of PathExact, PathPrefix, or PathRegex must be specified. + PathPrefix string `json:"pathPrefix,omitempty"` + // Regex to match. Exactly one of PathExact, PathPrefix, or PathRegex must be specified. + PathRegex string `json:"pathRegex,omitempty"` + + // RequestsPerSecond is the average number of requests per + // second that can be made without being throttled. This field is required + // if RequestsMaxBurst is set. The allowed number of requests may exceed + // RequestsPerSecond up to the value specified in RequestsMaxBurst. + // Internally, this is the refill rate of the token bucket used for rate limiting. + RequestsPerSecond int `json:"requestsPerSecond,omitempty"` + + // RequestsMaxBurst is the maximum number of requests that can be sent + // in a burst. Should be equal to or greater than RequestsPerSecond. If unset, + // defaults to RequestsPerSecond. Internally, this is the maximum size of the token + // bucket used for rate limiting. + RequestsMaxBurst int `json:"requestsMaxBurst,omitempty"` +} + func (in *ServiceDefaults) ConsulKind() string { return capi.ServiceDefaults } @@ -308,6 +456,7 @@ func (in *ServiceDefaults) ToConsul(datacenter string) capi.ConfigEntry { LocalConnectTimeoutMs: in.Spec.LocalConnectTimeoutMs, LocalRequestTimeoutMs: in.Spec.LocalRequestTimeoutMs, BalanceInboundConnections: in.Spec.BalanceInboundConnections, + RateLimits: in.Spec.RateLimits.toConsul(), EnvoyExtensions: in.Spec.EnvoyExtensions.toConsul(), } } @@ -356,6 +505,7 @@ func (in *ServiceDefaults) Validate(consulMeta common.ConsulMeta) error { allErrs = append(allErrs, in.Spec.UpstreamConfig.validate(path.Child("upstreamConfig"), consulMeta.PartitionsEnabled)...) allErrs = append(allErrs, in.Spec.Expose.validate(path.Child("expose"))...) + allErrs = append(allErrs, in.Spec.RateLimits.validate(path.Child("rateLimits"))...) allErrs = append(allErrs, in.Spec.EnvoyExtensions.validate(path.Child("envoyExtensions"))...) if len(allErrs) > 0 { diff --git a/control-plane/api/v1alpha1/servicedefaults_types_test.go b/control-plane/api/v1alpha1/servicedefaults_types_test.go index 31a41f3f06..2292e55791 100644 --- a/control-plane/api/v1alpha1/servicedefaults_types_test.go +++ b/control-plane/api/v1alpha1/servicedefaults_types_test.go @@ -160,6 +160,23 @@ func TestServiceDefaults_ToConsul(t *testing.T) { }, }, BalanceInboundConnections: "exact_balance", + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + RequestsMaxBurst: 2345, + Routes: []InstanceLevelRouteRateLimits{ + { + PathExact: "/foo", + RequestsPerSecond: 111, + RequestsMaxBurst: 222, + }, + { + PathPrefix: "/admin", + RequestsPerSecond: 333, + }, + }, + }, + }, EnvoyExtensions: EnvoyExtensions{ EnvoyExtension{ Name: "aws_request_signing", @@ -288,6 +305,23 @@ func TestServiceDefaults_ToConsul(t *testing.T) { }, }, BalanceInboundConnections: "exact_balance", + RateLimits: &capi.RateLimits{ + InstanceLevel: capi.InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + RequestsMaxBurst: 2345, + Routes: []capi.InstanceLevelRouteRateLimits{ + { + PathExact: "/foo", + RequestsPerSecond: 111, + RequestsMaxBurst: 222, + }, + { + PathPrefix: "/admin", + RequestsPerSecond: 333, + }, + }, + }, + }, EnvoyExtensions: []capi.EnvoyExtension{ { Name: "aws_request_signing", @@ -559,6 +593,23 @@ func TestServiceDefaults_MatchesConsul(t *testing.T) { }, }, BalanceInboundConnections: "exact_balance", + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + RequestsMaxBurst: 2345, + Routes: []InstanceLevelRouteRateLimits{ + { + PathExact: "/foo", + RequestsPerSecond: 111, + RequestsMaxBurst: 222, + }, + { + PathPrefix: "/admin", + RequestsPerSecond: 333, + }, + }, + }, + }, EnvoyExtensions: EnvoyExtensions{ EnvoyExtension{ Name: "aws_request_signing", @@ -680,6 +731,23 @@ func TestServiceDefaults_MatchesConsul(t *testing.T) { }, }, BalanceInboundConnections: "exact_balance", + RateLimits: &capi.RateLimits{ + InstanceLevel: capi.InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + RequestsMaxBurst: 2345, + Routes: []capi.InstanceLevelRouteRateLimits{ + { + PathExact: "/foo", + RequestsPerSecond: 111, + RequestsMaxBurst: 222, + }, + { + PathPrefix: "/admin", + RequestsPerSecond: 333, + }, + }, + }, + }, EnvoyExtensions: []capi.EnvoyExtension{ { Name: "aws_request_signing", @@ -1329,6 +1397,152 @@ func TestServiceDefaults_Validate(t *testing.T) { }, expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.envoyExtensions.envoyExtension[0].arguments: Invalid value: "{\"SOME_INVALID_JSON\"}": must be valid map value: invalid character '}' after object key`, }, + "rateLimits.instanceLevel.requestsPerSecond (negative value)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsPerSecond: -1, + RequestsMaxBurst: 0, + Routes: []InstanceLevelRouteRateLimits{ + { + PathPrefix: "/admin", + RequestsPerSecond: 222, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.rateLimits.instanceLevel.requestsPerSecond: Invalid value: -1: RequestsPerSecond must be positive`, + }, + "rateLimits.instanceLevel.requestsPerSecond (invalid value)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsMaxBurst: 1000, + Routes: []InstanceLevelRouteRateLimits{ + { + PathPrefix: "/admin", + RequestsPerSecond: 222, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.rateLimits.instanceLevel.requestsPerSecond: Invalid value: 0: RequestsPerSecond must be greater than 0 if RequestsMaxBurst is set`, + }, + "rateLimits.instanceLevel.requestsMaxBurst (negative value)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsMaxBurst: -1, + Routes: []InstanceLevelRouteRateLimits{ + { + PathPrefix: "/admin", + RequestsPerSecond: 222, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.rateLimits.instanceLevel.requestsMaxBurst: Invalid value: -1: RequestsMaxBurst must be positive`, + }, + "rateLimits.instanceLevel.routes (invalid path)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + RequestsMaxBurst: 2345, + Routes: []InstanceLevelRouteRateLimits{ + { + RequestsPerSecond: 222, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.rateLimits.instanceLevel.routes[0]: Required value: Route must define exactly one of PathExact, PathPrefix, or PathRegex`, + }, + "rateLimits.instanceLevel.routes.requestsPerSecond (zero value)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + Routes: []InstanceLevelRouteRateLimits{ + { + PathExact: "/", + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.rateLimits.instanceLevel.routes[0].requestsPerSecond: Invalid value: 0: RequestsPerSecond must be greater than 0`, + }, + "rateLimits.instanceLevel.routes.requestsMaxBurst (negative value)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + RequestsPerSecond: 1234, + Routes: []InstanceLevelRouteRateLimits{ + { + PathExact: "/", + RequestsPerSecond: 222, + RequestsMaxBurst: -1, + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: spec.rateLimits.instanceLevel.routes[0].requestsMaxBurst: Invalid value: -1: RequestsMaxBurst must be positive`, + }, + "rateLimits.requestsMaxBurst (top-level and route-level unset)": { + input: &ServiceDefaults{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-service", + }, + Spec: ServiceDefaultsSpec{ + RateLimits: &RateLimits{ + InstanceLevel: InstanceLevelRateLimits{ + Routes: []InstanceLevelRouteRateLimits{ + { + PathExact: "/", + }, + }, + }, + }, + }, + }, + expectedErrMsg: `servicedefaults.consul.hashicorp.com "my-service" is invalid: [spec.rateLimits.instanceLevel.routes[0].requestsPerSecond: Invalid value: 0: RequestsPerSecond must be greater than 0, spec.rateLimits.instanceLevel.requestsPerSecond: Invalid value: 0: At least one of top-level or route-level RequestsPerSecond must be set]`, + }, } for name, testCase := range cases { diff --git a/control-plane/api/v1alpha1/zz_generated.deepcopy.go b/control-plane/api/v1alpha1/zz_generated.deepcopy.go index 6786b38004..5b54f4a5c5 100644 --- a/control-plane/api/v1alpha1/zz_generated.deepcopy.go +++ b/control-plane/api/v1alpha1/zz_generated.deepcopy.go @@ -860,6 +860,41 @@ func (in *IngressServiceConfig) DeepCopy() *IngressServiceConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceLevelRateLimits) DeepCopyInto(out *InstanceLevelRateLimits) { + *out = *in + if in.Routes != nil { + in, out := &in.Routes, &out.Routes + *out = make([]InstanceLevelRouteRateLimits, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceLevelRateLimits. +func (in *InstanceLevelRateLimits) DeepCopy() *InstanceLevelRateLimits { + if in == nil { + return nil + } + out := new(InstanceLevelRateLimits) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceLevelRouteRateLimits) DeepCopyInto(out *InstanceLevelRouteRateLimits) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceLevelRouteRateLimits. +func (in *InstanceLevelRouteRateLimits) DeepCopy() *InstanceLevelRouteRateLimits { + if in == nil { + return nil + } + out := new(InstanceLevelRouteRateLimits) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *IntentionDestination) DeepCopyInto(out *IntentionDestination) { *out = *in @@ -2080,6 +2115,22 @@ func (in *ProxyDefaultsSpec) DeepCopy() *ProxyDefaultsSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RateLimits) DeepCopyInto(out *RateLimits) { + *out = *in + in.InstanceLevel.DeepCopyInto(&out.InstanceLevel) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RateLimits. +func (in *RateLimits) DeepCopy() *RateLimits { + if in == nil { + return nil + } + out := new(RateLimits) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ReadWriteRatesConfig) DeepCopyInto(out *ReadWriteRatesConfig) { *out = *in @@ -2576,6 +2627,11 @@ func (in *ServiceDefaultsSpec) DeepCopyInto(out *ServiceDefaultsSpec) { *out = new(ServiceDefaultsDestination) (*in).DeepCopyInto(*out) } + if in.RateLimits != nil { + in, out := &in.RateLimits, &out.RateLimits + *out = new(RateLimits) + (*in).DeepCopyInto(*out) + } if in.EnvoyExtensions != nil { in, out := &in.EnvoyExtensions, &out.EnvoyExtensions *out = make(EnvoyExtensions, len(*in)) diff --git a/control-plane/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml b/control-plane/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml index d4d639e55c..2b5ab54acd 100644 --- a/control-plane/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml +++ b/control-plane/config/crd/bases/consul.hashicorp.com_servicedefaults.yaml @@ -185,6 +185,69 @@ spec: unlock usage of the service-splitter and service-router config entries for a service. type: string + rateLimits: + description: RateLimits is rate limiting configuration that is applied + to inbound traffic for a service. Rate limiting is a Consul enterprise + feature. + properties: + instanceLevel: + description: InstanceLevel represents rate limit configuration + that is applied per service instance. + properties: + requestsMaxBurst: + description: "RequestsMaxBurst is the maximum number of requests + that can be sent in a burst. Should be equal to or greater + than RequestsPerSecond. If unset, defaults to RequestsPerSecond. + \n Internally, this is the maximum size of the token bucket + used for rate limiting." + type: integer + requestsPerSecond: + description: "RequestsPerSecond is the average number of requests + per second that can be made without being throttled. This + field is required if RequestsMaxBurst is set. The allowed + number of requests may exceed RequestsPerSecond up to the + value specified in RequestsMaxBurst. \n Internally, this + is the refill rate of the token bucket used for rate limiting." + type: integer + routes: + description: Routes is a list of rate limits applied to specific + routes. For a given request, the first matching route will + be applied, if any. Overrides any top-level configuration. + items: + properties: + pathExact: + description: Exact path to match. Exactly one of PathExact, + PathPrefix, or PathRegex must be specified. + type: string + pathPrefix: + description: Prefix to match. Exactly one of PathExact, + PathPrefix, or PathRegex must be specified. + type: string + pathRegex: + description: Regex to match. Exactly one of PathExact, + PathPrefix, or PathRegex must be specified. + type: string + requestsMaxBurst: + description: RequestsMaxBurst is the maximum number + of requests that can be sent in a burst. Should be + equal to or greater than RequestsPerSecond. If unset, + defaults to RequestsPerSecond. Internally, this is + the maximum size of the token bucket used for rate + limiting. + type: integer + requestsPerSecond: + description: RequestsPerSecond is the average number + of requests per second that can be made without being + throttled. This field is required if RequestsMaxBurst + is set. The allowed number of requests may exceed + RequestsPerSecond up to the value specified in RequestsMaxBurst. + Internally, this is the refill rate of the token bucket + used for rate limiting. + type: integer + type: object + type: array + type: object + type: object transparentProxy: description: 'TransparentProxy controls configuration specific to proxies in transparent mode. Note: This cannot be set using the diff --git a/control-plane/go.mod b/control-plane/go.mod index 3589eac8b7..e935420730 100644 --- a/control-plane/go.mod +++ b/control-plane/go.mod @@ -12,8 +12,10 @@ require ( github.com/go-logr/logr v1.2.3 github.com/google/go-cmp v0.5.9 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - github.com/hashicorp/consul/api v1.10.1-0.20230821180813-217d305b38d5 - github.com/hashicorp/consul/proto-public v0.1.2-0.20230829221456-f8812eddf1ef // this points to a commit on Consul main + github.com/hashicorp/consul-k8s/control-plane/cni v0.0.0-20230825213844-4ea04860c5ed + github.com/hashicorp/consul-server-connection-manager v0.1.4 + github.com/hashicorp/consul/api v1.10.1-0.20230825164720-ecdcde430924 + github.com/hashicorp/consul/proto-public v0.1.2-0.20230829221456-f8812eddf1ef github.com/hashicorp/consul/sdk v0.14.1 github.com/hashicorp/go-bexpr v0.1.11 github.com/hashicorp/go-discover v0.0.0-20230519164032-214571b6a530 @@ -35,6 +37,8 @@ require ( golang.org/x/text v0.11.0 golang.org/x/time v0.3.0 gomodules.xyz/jsonpatch/v2 v2.3.0 + google.golang.org/grpc v1.55.0 + google.golang.org/protobuf v1.30.0 gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.26.3 k8s.io/apimachinery v0.26.3 @@ -45,13 +49,6 @@ require ( sigs.k8s.io/gateway-api v0.7.1 ) -require ( - github.com/hashicorp/consul-k8s/control-plane/cni v0.0.0-20230825213844-4ea04860c5ed - github.com/hashicorp/consul-server-connection-manager v0.1.4 - google.golang.org/grpc v1.55.0 - google.golang.org/protobuf v1.30.0 -) - require ( cloud.google.com/go/compute v1.19.0 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect diff --git a/control-plane/go.sum b/control-plane/go.sum index 1a16c25165..8dca0b7203 100644 --- a/control-plane/go.sum +++ b/control-plane/go.sum @@ -263,8 +263,8 @@ github.com/hashicorp/consul-k8s/control-plane/cni v0.0.0-20230825213844-4ea04860 github.com/hashicorp/consul-k8s/control-plane/cni v0.0.0-20230825213844-4ea04860c5ed/go.mod h1:mwODEC+VTCA1LY/m2RUG4S2c5lNRvBcsvqaMJtMLLos= github.com/hashicorp/consul-server-connection-manager v0.1.4 h1:wrcSRV6WGXFBNpNbN6XsdoGgBOyso7ZbN5VaWPEX1jY= github.com/hashicorp/consul-server-connection-manager v0.1.4/go.mod h1:LMqHkALoLP0HUQKOG21xXYr0YPUayIQIHNTlmxG100E= -github.com/hashicorp/consul/api v1.10.1-0.20230821180813-217d305b38d5 h1:TTTgXv9YeaRnODyFP1k2b2Nq5RIGrUUgI5SkDhuSNwM= -github.com/hashicorp/consul/api v1.10.1-0.20230821180813-217d305b38d5/go.mod h1:NZJGRFYruc/80wYowkPFCp1LbGmJC9L8izrwfyVx/Wg= +github.com/hashicorp/consul/api v1.10.1-0.20230825164720-ecdcde430924 h1:NPhzdwDho2r8pQv31oeGLlco7fnJ1i0WLYjtSXqWEck= +github.com/hashicorp/consul/api v1.10.1-0.20230825164720-ecdcde430924/go.mod h1:NZJGRFYruc/80wYowkPFCp1LbGmJC9L8izrwfyVx/Wg= github.com/hashicorp/consul/proto-public v0.1.2-0.20230829221456-f8812eddf1ef h1:Vt5NSnXc+RslTxXH2pz7dCb3hnE33CD2TrBP5AIQtMg= github.com/hashicorp/consul/proto-public v0.1.2-0.20230829221456-f8812eddf1ef/go.mod h1:ENwzmloQTUPAYPu7nC1mli3VY0Ny9QNi/FSzJ+KlZD0= github.com/hashicorp/consul/sdk v0.4.1-0.20230825164720-ecdcde430924 h1:gkb6/ix0Tg1Th5FTjyq4QklLgrtIVQ/TUB0kbhIcPsY=