You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
boundary/internal/daemon/controller/rate_limiter_test.go

672 lines
16 KiB

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package controller
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/hashicorp/boundary/internal/cmd/config"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/boundary/internal/ratelimit"
"github.com/hashicorp/eventlogger/formatter_filters/cloudevents"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-rate"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func testLogger(t *testing.T, testLock hclog.Locker) hclog.Logger {
t.Helper()
return hclog.New(&hclog.LoggerOptions{
Mutex: testLock,
Name: "test",
JSONFormat: true,
})
}
func Test_newRateLimiterConfig(t *testing.T) {
ctx := context.Background()
var configs ratelimit.Configs
defaultLimits, err := configs.Limits(ctx)
require.NoError(t, err)
cases := []struct {
name string
configs ratelimit.Configs
maxSize int
disabled bool
want *rateLimiterConfig
wantErr error
}{
{
"disabled",
nil,
0,
true,
&rateLimiterConfig{disabled: true},
nil,
},
{
"defaults",
nil,
ratelimit.DefaultLimiterMaxQuotas(),
false,
&rateLimiterConfig{
maxSize: 338169,
configs: nil,
disabled: false,
limits: defaultLimits,
},
nil,
},
{
"disabledWithConfigs",
ratelimit.Configs{
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: "total",
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
},
ratelimit.DefaultLimiterMaxQuotas(),
true,
nil,
fmt.Errorf("controller.newRateLimiterConfig: disabled rate limiter with rate limit configs: configuration issue: error #5000"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := newRateLimiterConfig(ctx, tc.configs, tc.maxSize, tc.disabled)
if tc.wantErr != nil {
assert.EqualError(t, err, tc.wantErr.Error())
return
}
require.NoError(t, err)
assert.Empty(t, cmp.Diff(
tc.want,
got,
cmp.AllowUnexported(rateLimiterConfig{}),
cmpopts.IgnoreFields(rateLimiterConfig{}, "limits"),
))
assert.ElementsMatch(t, got.limits, tc.want.limits)
})
}
}
func TestController_initializeRateLimiter(t *testing.T) {
// Disabling eventing so reduce noise.
event.TestWithoutEventing(t)
cases := []struct {
name string
conf *config.Config
wantNopLimiter bool
wantErr error
}{
{
"disabled",
&config.Config{
Controller: &config.Controller{
ApiRateLimitDisable: true,
},
},
true,
nil,
},
{
"defaults",
&config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
},
false,
nil,
},
{
"invalid",
&config.Config{
Controller: &config.Controller{
ApiRateLimits: ratelimit.Configs{
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: "total",
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
},
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
ApiRateLimitDisable: true,
},
},
false,
fmt.Errorf("controller.newRateLimiterConfig: disabled rate limiter with rate limit configs: configuration issue: error #5000"),
},
{
"nilConfig",
nil,
false,
fmt.Errorf("controller.(Controller).initializeRateLimiter: nil config: parameter violation: error #100"),
},
{
"nilConfigController",
&config.Config{
Controller: nil,
},
false,
fmt.Errorf("controller.(Controller).initializeRateLimiter: nil config.Controller: parameter violation: error #100"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: tc.conf,
},
}
err := c.initializeRateLimiter(tc.conf)
if tc.wantErr != nil {
assert.EqualError(t, err, tc.wantErr.Error())
return
}
require.NoError(t, err)
if tc.wantNopLimiter {
assert.Equal(t, rate.NopLimiter, c.rateLimiter)
return
}
_, ok := c.rateLimiter.(*rate.Limiter)
assert.True(t, ok, "expected rate.Limiter")
assert.NotNil(t, c.rateLimiter)
})
}
}
func TestControllerReloadRateLimiter(t *testing.T) {
// Disabling eventing so reduce noise.
event.TestWithoutEventing(t)
cases := []struct {
name string
c *Controller
conf *config.Config
wantNewLimiter bool
wantErr error
}{
{
"newConfigs",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimits: ratelimit.Configs{
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: "total",
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
},
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
},
true,
nil,
},
{
"newMaxSize",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: 3000,
},
},
true,
nil,
},
{
"newDisabled",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimitDisable: true,
},
},
true,
nil,
},
{
"newEnabled",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimitDisable: true,
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
},
true,
nil,
},
{
"newInvalid",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
ApiRateLimits: ratelimit.Configs{
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: "total",
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
},
ApiRateLimitDisable: true,
},
},
false,
fmt.Errorf("controller.newRateLimiterConfig: disabled rate limiter with rate limit configs: configuration issue: error #5000"),
},
{
"newInvalidMaxSize",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: 0,
ApiRateLimits: ratelimit.Configs{
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: "total",
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
},
},
},
false,
fmt.Errorf("controller.(Controller).ReloadRateLimiter: unknown, unknown: error #0: rate.NewLimiter: rate.newExpirableStore: max size must be greater than zero: invalid max size"),
},
{
"newConfigsNoChange",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
},
false,
nil,
},
{
"nilNewConfigController",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
&config.Config{
Controller: nil,
},
false,
fmt.Errorf("controller.(Controller).ReloadRateLimiter: nil config.Controller: parameter violation: error #100"),
},
{
"nilNewConfig",
func() *Controller {
conf := &config.Config{
Controller: &config.Controller{
ApiRateLimiterMaxQuotas: ratelimit.DefaultLimiterMaxQuotas(),
},
}
c := &Controller{
baseContext: context.Background(),
conf: &Config{
RawConfig: conf,
},
}
err := c.initializeRateLimiter(conf)
require.NoError(t, err)
return c
}(),
nil,
false,
fmt.Errorf("controller.(Controller).ReloadRateLimiter: nil config: parameter violation: error #100"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
prevLimiter := tc.c.getRateLimiter()
err := tc.c.ReloadRateLimiter(tc.conf)
if tc.wantErr != nil {
assert.EqualError(t, err, tc.wantErr.Error())
} else {
require.NoError(t, err)
}
if tc.wantNewLimiter {
assert.NotSame(t, prevLimiter, tc.c.getRateLimiter())
return
}
assert.Same(t, prevLimiter, tc.c.getRateLimiter())
})
}
}
func Test_rateLimiterConfig_writeSysEvent(t *testing.T) {
c := event.TestEventerConfig(t, t.Name())
testLock := &sync.Mutex{}
testLogger := testLogger(t, testLock)
e, err := event.NewEventer(testLogger, testLock, t.Name(), c.EventerConfig)
require.NoError(t, err)
info := &event.RequestInfo{Id: "867-5309", EventId: "411"}
testCtx, err := event.NewEventerContext(context.Background(), e)
require.NoError(t, err)
testCtx, err = event.NewRequestInfoContext(testCtx, info)
require.NoError(t, err)
cases := []struct {
name string
setup func(n string) error
cleanup func()
sinkFileName string
configs ratelimit.Configs
maxSize int
disabled bool
}{
{
name: "defaults",
setup: func(n string) error {
return event.InitSysEventer(testLogger, testLock, n, event.WithEventerConfig(&c.EventerConfig))
},
cleanup: func() { event.TestResetSystEventer(t) },
sinkFileName: c.AllEvents.Name(),
configs: nil,
maxSize: ratelimit.DefaultLimiterMaxQuotas(),
disabled: false,
},
{
name: "override",
setup: func(n string) error {
return event.InitSysEventer(testLogger, testLock, n, event.WithEventerConfig(&c.EventerConfig))
},
cleanup: func() { event.TestResetSystEventer(t) },
sinkFileName: c.AllEvents.Name(),
configs: ratelimit.Configs{
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: rate.LimitPerTotal.String(),
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: rate.LimitPerIPAddress.String(),
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
{
Resources: []string{"*"},
Actions: []string{"*"},
Per: rate.LimitPerAuthToken.String(),
Limit: 100,
Period: time.Minute,
Unlimited: false,
},
},
maxSize: ratelimit.DefaultLimiterMaxQuotas(),
disabled: false,
},
{
name: "max_size",
setup: func(n string) error {
return event.InitSysEventer(testLogger, testLock, n, event.WithEventerConfig(&c.EventerConfig))
},
cleanup: func() { event.TestResetSystEventer(t) },
sinkFileName: c.AllEvents.Name(),
configs: nil,
maxSize: 3000,
disabled: false,
},
{
name: "disabled",
setup: func(n string) error {
return event.InitSysEventer(testLogger, testLock, n, event.WithEventerConfig(&c.EventerConfig))
},
cleanup: func() { event.TestResetSystEventer(t) },
sinkFileName: c.AllEvents.Name(),
configs: nil,
maxSize: 0,
disabled: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.setup != nil {
require.NoError(t, tc.setup(t.Name()))
}
if tc.cleanup != nil {
defer tc.cleanup()
}
wantFile, err := os.Open(filepath.Join("testdata", t.Name()+".json"))
require.NoError(t, err)
defer wantFile.Close()
want := &cloudevents.Event{}
wantDecoder := json.NewDecoder(wantFile)
err = wantDecoder.Decode(want)
require.NoError(t, err)
rlc, err := newRateLimiterConfig(testCtx, tc.configs, tc.maxSize, tc.disabled)
require.NoError(t, err)
rlc.writeSysEvent(testCtx)
defer func() { _ = os.WriteFile(tc.sinkFileName, nil, 0o666) }()
b, err := os.ReadFile(tc.sinkFileName)
require.NoError(t, err)
got := &cloudevents.Event{}
err = json.Unmarshal(b, got)
require.NoErrorf(t, err, "json: %s", string(b))
assert.Empty(t, cmp.Diff(
got,
want,
cmpopts.IgnoreFields(cloudevents.Event{}, "ID", "Time", "Data"),
))
gotData := got.Data.(map[string]interface{})
wantData := want.Data.(map[string]interface{})
assert.Equal(t, len(gotData), len(wantData))
for k, v := range wantData {
switch k {
case "data":
wantDataData := v.(map[string]interface{})
gotDataData := gotData[k].(map[string]interface{})
assert.Equal(t, len(gotDataData), len(wantDataData))
for k, v := range wantDataData {
switch k {
case "limits":
wantResources := v.(map[string]interface{})
gotResources := gotDataData[k].(map[string]interface{})
for k, v := range wantResources {
gotv, ok := gotResources[k]
require.True(t, ok)
wantResourceLimits := v.(map[string]interface{})
gotResourceLimits := gotv.(map[string]interface{})
require.Equal(t, len(wantResourceLimits), len(gotResourceLimits))
for k, v := range wantResourceLimits {
gotv, ok := gotResourceLimits[k]
require.True(t, ok)
gotActionLimits := v.([]interface{})
wantActionLimits := gotv.([]interface{})
require.Equal(t, len(gotActionLimits), len(wantActionLimits))
assert.ElementsMatch(t, gotActionLimits, wantActionLimits)
}
}
case "max_size", "msg", "disabled":
assert.Equal(t, v, gotDataData[k])
default:
require.Fail(t, "unexpected key %s", k)
}
}
case "op", "version":
assert.Equal(t, v, gotData[k])
default:
require.Fail(t, "unexpected key %s", k)
}
}
})
}
}