feat(store/valkey): Add Redis(R) Sentinel support (#1294)
* feat(internal): add ListOr[T any] type This is a utility type that lets you decode a JSON T or list of T as a single value. This will be used with Redis Sentinel config so that you can specify multiple sentinel addresses. Ref TecharoHQ/botstopper#24 Assisted-by: GLM 4.6 via Claude Code Signed-off-by: Xe Iaso <me@xeiaso.net> * feat(store/valkey): add Redis(R) Sentinel support Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: spelling check-spelling run (pull_request) for Xe/redis-sentinel Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com> on-behalf-of: @check-spelling <check-spelling-bot@check-spelling.dev> * chore(store/valkey): remove pointless comments Signed-off-by: Xe Iaso <me@xeiaso.net> * docs: document the Redis™ Sentinel configuration options Signed-off-by: Xe Iaso <me@xeiaso.net> * fix(store/valkey): Redis™ Sentinel doesn't require a password Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: spelling Signed-off-by: Xe Iaso <me@xeiaso.net> * chore: spelling Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net> Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
This commit is contained in:
parent
69e9023cbb
commit
02989f03d0
7 changed files with 321 additions and 24 deletions
3
.github/actions/spelling/allow.txt
vendored
3
.github/actions/spelling/allow.txt
vendored
|
|
@ -8,4 +8,5 @@ msgbox
|
||||||
xeact
|
xeact
|
||||||
ABee
|
ABee
|
||||||
tencent
|
tencent
|
||||||
maintnotifications
|
maintnotifications
|
||||||
|
azurediamond
|
||||||
|
|
|
||||||
6
.github/actions/spelling/expect.txt
vendored
6
.github/actions/spelling/expect.txt
vendored
|
|
@ -200,6 +200,7 @@ licstart
|
||||||
lightpanda
|
lightpanda
|
||||||
limsa
|
limsa
|
||||||
Linting
|
Linting
|
||||||
|
listor
|
||||||
LLU
|
LLU
|
||||||
loadbalancer
|
loadbalancer
|
||||||
lol
|
lol
|
||||||
|
|
@ -217,6 +218,10 @@ mnt
|
||||||
Mojeek
|
Mojeek
|
||||||
mojeekbot
|
mojeekbot
|
||||||
mozilla
|
mozilla
|
||||||
|
myclient
|
||||||
|
mymaster
|
||||||
|
mypass
|
||||||
|
myuser
|
||||||
nbf
|
nbf
|
||||||
nepeat
|
nepeat
|
||||||
netsurf
|
netsurf
|
||||||
|
|
@ -267,7 +272,6 @@ qwantbot
|
||||||
rac
|
rac
|
||||||
rawler
|
rawler
|
||||||
rcvar
|
rcvar
|
||||||
rdb
|
|
||||||
redhat
|
redhat
|
||||||
redir
|
redir
|
||||||
redirectscheme
|
redirectscheme
|
||||||
|
|
|
||||||
|
|
@ -225,10 +225,10 @@ Using this backend will cause a lot of S3 operations, at least one for creating
|
||||||
|
|
||||||
The `s3api` backend takes the following configuration options:
|
The `s3api` backend takes the following configuration options:
|
||||||
|
|
||||||
| Name | Type | Example | Description |
|
| Name | Type | Example | Description |
|
||||||
| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
|
| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `bucketName` | string | The name of the dedicated bucket for Anubis to store information in. |
|
| `bucketName` | string | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in. |
|
||||||
| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
|
| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
|
|
@ -279,7 +279,7 @@ store:
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
You can also use [Redis](http://redis.io/) with Anubis.
|
You can also use [Redis™](http://redis.io/) with Anubis.
|
||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
@ -291,15 +291,17 @@ This backend is ideal if you are running multiple instances of Anubis in a worke
|
||||||
| Does your service get a lot of traffic? | ✅ Yes |
|
| Does your service get a lot of traffic? | ✅ Yes |
|
||||||
| Do you want to store data persistently when Anubis restarts? | ✅ Yes |
|
| Do you want to store data persistently when Anubis restarts? | ✅ Yes |
|
||||||
| Do you run Anubis without mutable filesystem storage? | ✅ Yes |
|
| Do you run Anubis without mutable filesystem storage? | ✅ Yes |
|
||||||
| Do you have Redis or Valkey installed? | ✅ Yes |
|
| Do you have Redis™ or Valkey installed? | ✅ Yes |
|
||||||
|
|
||||||
#### Configuration
|
#### Configuration
|
||||||
|
|
||||||
The `valkey` backend takes the following configuration options:
|
The `valkey` backend takes the following configuration options:
|
||||||
|
|
||||||
| Name | Type | Example | Description |
|
| Name | Type | Example | Description |
|
||||||
| :---- | :----- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- |
|
| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
|
| `cluster` | bool | `false` | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data. |
|
||||||
|
| `sentinel` | object | `{}` | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples |
|
||||||
|
| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis™ or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|
@ -314,6 +316,18 @@ store:
|
||||||
|
|
||||||
This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).
|
This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).
|
||||||
|
|
||||||
|
#### Redis™ Sentinel
|
||||||
|
|
||||||
|
If you are using [Redis™ Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) for a high availability setup, you need to configure the `sentinel` object. This object takes the following configuration options:
|
||||||
|
|
||||||
|
| Name | Type | Example | Description |
|
||||||
|
| :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `addr` | string or list of string | `10.43.208.130:26379` | (Required) The host and port of the Redis™ Sentinel server. When possible, use DNS names for this. If you have multiple addresses, supply a list of them. |
|
||||||
|
| `clientName` | string | `Anubis` | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel. |
|
||||||
|
| `masterName` | string | `mymaster` | (Required) The name of the master in the Redis™ Sentinel configuration. This is used to discover where to find client connection hosts/ports. |
|
||||||
|
| `username` | string | `azurediamond` | The username used to authenticate against the Redis™ Sentinel and Redis™ servers. |
|
||||||
|
| `password` | string | `hunter2` | The password used to authenticate against the Redis™ Sentinel and Redis™ servers. |
|
||||||
|
|
||||||
## Risk calculation for downstream services
|
## Risk calculation for downstream services
|
||||||
|
|
||||||
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:
|
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:
|
||||||
|
|
|
||||||
39
internal/listor.go
Normal file
39
internal/listor.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListOr[T any] is a slice that can contain either a single T or multiple T values.
|
||||||
|
// During JSON unmarshaling, it checks if the first character is '[' to determine
|
||||||
|
// whether to treat the JSON as an array or a single value.
|
||||||
|
type ListOr[T any] []T
|
||||||
|
|
||||||
|
func (lo *ListOr[T]) UnmarshalJSON(data []byte) error {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first non-whitespace character is '['
|
||||||
|
firstChar := data[0]
|
||||||
|
for i := 0; i < len(data); i++ {
|
||||||
|
if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' {
|
||||||
|
firstChar = data[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstChar == '[' {
|
||||||
|
// It's an array, unmarshal directly
|
||||||
|
return json.Unmarshal(data, (*[]T)(lo))
|
||||||
|
} else {
|
||||||
|
// It's a single value, unmarshal as a single item in a slice
|
||||||
|
var single T
|
||||||
|
if err := json.Unmarshal(data, &single); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*lo = ListOr[T]{single}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
79
internal/listor_test.go
Normal file
79
internal/listor_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListOr_UnmarshalJSON(t *testing.T) {
|
||||||
|
t.Run("single value should be unmarshaled as single item", func(t *testing.T) {
|
||||||
|
var lo ListOr[string]
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(`"hello"`), &lo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal single string: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lo) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item, got %d", len(lo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if lo[0] != "hello" {
|
||||||
|
t.Errorf("Expected 'hello', got %q", lo[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("array should be unmarshaled as multiple items", func(t *testing.T) {
|
||||||
|
var lo ListOr[string]
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(`["hello", "world"]`), &lo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal array: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lo) != 2 {
|
||||||
|
t.Fatalf("Expected 2 items, got %d", len(lo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if lo[0] != "hello" {
|
||||||
|
t.Errorf("Expected 'hello', got %q", lo[0])
|
||||||
|
}
|
||||||
|
if lo[1] != "world" {
|
||||||
|
t.Errorf("Expected 'world', got %q", lo[1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("single number should be unmarshaled as single item", func(t *testing.T) {
|
||||||
|
var lo ListOr[int]
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(`42`), &lo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal single number: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lo) != 1 {
|
||||||
|
t.Fatalf("Expected 1 item, got %d", len(lo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if lo[0] != 42 {
|
||||||
|
t.Errorf("Expected 42, got %d", lo[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) {
|
||||||
|
var lo ListOr[int]
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal number array: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lo) != 3 {
|
||||||
|
t.Fatalf("Expected 3 items, got %d", len(lo))
|
||||||
|
}
|
||||||
|
|
||||||
|
if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 {
|
||||||
|
t.Errorf("Expected [1, 2, 3], got %v", lo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/TecharoHQ/anubis/internal"
|
||||||
"github.com/TecharoHQ/anubis/lib/store"
|
"github.com/TecharoHQ/anubis/lib/store"
|
||||||
valkey "github.com/redis/go-redis/v9"
|
valkey "github.com/redis/go-redis/v9"
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
|
|
@ -16,26 +17,84 @@ func init() {
|
||||||
store.Register("valkey", Factory{})
|
store.Register("valkey", Factory{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors kept as-is so other code/tests still pass.
|
|
||||||
var (
|
var (
|
||||||
ErrNoURL = errors.New("valkey.Config: no URL defined")
|
ErrNoURL = errors.New("valkey.Config: no URL defined")
|
||||||
ErrBadURL = errors.New("valkey.Config: URL is invalid")
|
ErrBadURL = errors.New("valkey.Config: URL is invalid")
|
||||||
|
|
||||||
|
// Sentinel validation errors
|
||||||
|
ErrSentinelMasterNameRequired = errors.New("valkey.Sentinel: masterName is required")
|
||||||
|
ErrSentinelAddrRequired = errors.New("valkey.Sentinel: addr is required")
|
||||||
|
ErrSentinelAddrEmpty = errors.New("valkey.Sentinel: addr cannot be empty")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is what Anubis unmarshals from the "parameters" JSON.
|
// Config is what Anubis unmarshals from the "parameters" JSON.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Cluster bool `json:"cluster,omitempty"`
|
Cluster bool `json:"cluster,omitempty"`
|
||||||
|
|
||||||
|
Sentinel *Sentinel `json:"sentinel,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) Valid() error {
|
func (c Config) Valid() error {
|
||||||
if c.URL == "" {
|
var errs []error
|
||||||
return ErrNoURL
|
|
||||||
|
if c.URL == "" && c.Sentinel == nil {
|
||||||
|
errs = append(errs, ErrNoURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just validate that it's a valid Redis URL.
|
// Validate URL only if provided
|
||||||
if _, err := valkey.ParseURL(c.URL); err != nil {
|
if c.URL != "" {
|
||||||
return fmt.Errorf("%w: %v", ErrBadURL, err)
|
if _, err := valkey.ParseURL(c.URL); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("%w: %v", ErrBadURL, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Sentinel != nil {
|
||||||
|
if err := c.Sentinel.Valid(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sentinel struct {
|
||||||
|
MasterName string `json:"masterName"`
|
||||||
|
Addr internal.ListOr[string] `json:"addr"`
|
||||||
|
ClientName string `json:"clientName,omitempty"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Sentinel) Valid() error {
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
if s.MasterName == "" {
|
||||||
|
errs = append(errs, ErrSentinelMasterNameRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.Addr) == 0 {
|
||||||
|
errs = append(errs, ErrSentinelAddrRequired)
|
||||||
|
} else {
|
||||||
|
// Check if all addresses in the list are empty
|
||||||
|
allEmpty := true
|
||||||
|
for _, addr := range s.Addr {
|
||||||
|
if addr != "" {
|
||||||
|
allEmpty = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allEmpty {
|
||||||
|
errs = append(errs, ErrSentinelAddrEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -68,14 +127,15 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
opts, err := valkey.ParseURL(cfg.URL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("valkey.Factory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var client redisClient
|
var client redisClient
|
||||||
|
|
||||||
if cfg.Cluster {
|
switch {
|
||||||
|
case cfg.Cluster:
|
||||||
|
opts, err := valkey.ParseURL(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("valkey.Factory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Cluster mode: use the parsed Addr as the seed node.
|
// Cluster mode: use the parsed Addr as the seed node.
|
||||||
clusterOpts := &valkey.ClusterOptions{
|
clusterOpts := &valkey.ClusterOptions{
|
||||||
Addrs: []string{opts.Addr},
|
Addrs: []string{opts.Addr},
|
||||||
|
|
@ -86,7 +146,23 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
client = valkey.NewClusterClient(clusterOpts)
|
client = valkey.NewClusterClient(clusterOpts)
|
||||||
} else {
|
case cfg.Sentinel != nil:
|
||||||
|
opts := &valkey.FailoverOptions{
|
||||||
|
MasterName: cfg.Sentinel.MasterName,
|
||||||
|
SentinelAddrs: cfg.Sentinel.Addr,
|
||||||
|
SentinelUsername: cfg.Sentinel.Username,
|
||||||
|
SentinelPassword: cfg.Sentinel.Password,
|
||||||
|
Username: cfg.Sentinel.Username,
|
||||||
|
Password: cfg.Sentinel.Password,
|
||||||
|
ClientName: cfg.Sentinel.ClientName,
|
||||||
|
}
|
||||||
|
client = valkey.NewFailoverClusterClient(opts)
|
||||||
|
default:
|
||||||
|
opts, err := valkey.ParseURL(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("valkey.Factory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
opts.MaintNotificationsConfig = &maintnotifications.Config{
|
opts.MaintNotificationsConfig = &maintnotifications.Config{
|
||||||
Mode: maintnotifications.ModeDisabled,
|
Mode: maintnotifications.ModeDisabled,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package valkey
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
|
@ -45,3 +46,86 @@ func TestImpl(t *testing.T) {
|
||||||
|
|
||||||
storetest.Common(t, Factory{}, json.RawMessage(data))
|
storetest.Common(t, Factory{}, json.RawMessage(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFactoryValid(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
jsonData string
|
||||||
|
expectError error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty config",
|
||||||
|
jsonData: `{}`,
|
||||||
|
expectError: ErrNoURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid URL only",
|
||||||
|
jsonData: `{"url": "redis://localhost:6379"}`,
|
||||||
|
expectError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid URL",
|
||||||
|
jsonData: `{"url": "invalid-url"}`,
|
||||||
|
expectError: ErrBadURL,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid sentinel config",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass"}}`,
|
||||||
|
expectError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel missing masterName",
|
||||||
|
jsonData: `{"sentinel": {"addr": ["localhost:26379"], "password": "mypass"}}`,
|
||||||
|
expectError: ErrSentinelMasterNameRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel missing addr",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "password": "mypass"}}`,
|
||||||
|
expectError: ErrSentinelAddrRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel empty addr",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": [""], "password": "mypass"}}`,
|
||||||
|
expectError: ErrSentinelAddrEmpty,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel missing password",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"]}}`,
|
||||||
|
expectError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel with optional fields",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass", "clientName": "myclient", "username": "myuser"}}`,
|
||||||
|
expectError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel single address (not array)",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": "localhost:26379", "password": "mypass"}}`,
|
||||||
|
expectError: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sentinel mixed empty and valid addresses",
|
||||||
|
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["", "localhost:26379", ""], "password": "mypass"}}`,
|
||||||
|
expectError: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
factory := Factory{}
|
||||||
|
err := factory.Valid(json.RawMessage(tt.jsonData))
|
||||||
|
|
||||||
|
if tt.expectError == nil {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected no error, got: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error %v, got nil", tt.expectError)
|
||||||
|
} else if !errors.Is(err, tt.expectError) {
|
||||||
|
t.Errorf("expected error %v, got: %v", tt.expectError, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue