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:
Xe Iaso 2025-11-18 09:55:19 -05:00 committed by GitHub
parent 69e9023cbb
commit 02989f03d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 321 additions and 24 deletions

View file

@ -8,4 +8,5 @@ msgbox
xeact
ABee
tencent
maintnotifications
maintnotifications
azurediamond

View file

@ -200,6 +200,7 @@ licstart
lightpanda
limsa
Linting
listor
LLU
loadbalancer
lol
@ -217,6 +218,10 @@ mnt
Mojeek
mojeekbot
mozilla
myclient
mymaster
mypass
myuser
nbf
nepeat
netsurf
@ -267,7 +272,6 @@ qwantbot
rac
rawler
rcvar
rdb
redhat
redir
redirectscheme

View file

@ -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:
| Name | Type | Example | Description |
| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `bucketName` | string | 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. |
| Name | Type | Example | Description |
| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `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. |
:::note
@ -279,7 +279,7 @@ store:
:::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 |
| Do you want to store data persistently when Anubis restarts? | ✅ 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
The `valkey` backend takes the following configuration options:
| 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. |
| Name | Type | Example | Description |
| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| `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:
@ -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).
#### 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
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
View 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
View 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)
}
})
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"time"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
@ -16,26 +17,84 @@ func init() {
store.Register("valkey", Factory{})
}
// Errors kept as-is so other code/tests still pass.
var (
ErrNoURL = errors.New("valkey.Config: no URL defined")
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.
type Config struct {
URL string `json:"url"`
Cluster bool `json:"cluster,omitempty"`
Sentinel *Sentinel `json:"sentinel,omitempty"`
}
func (c Config) Valid() error {
if c.URL == "" {
return ErrNoURL
var errs []error
if c.URL == "" && c.Sentinel == nil {
errs = append(errs, ErrNoURL)
}
// Just validate that it's a valid Redis URL.
if _, err := valkey.ParseURL(c.URL); err != nil {
return fmt.Errorf("%w: %v", ErrBadURL, err)
// Validate URL only if provided
if c.URL != "" {
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
@ -68,14 +127,15 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
return nil, err
}
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
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.
clusterOpts := &valkey.ClusterOptions{
Addrs: []string{opts.Addr},
@ -86,7 +146,23 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
},
}
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{
Mode: maintnotifications.ModeDisabled,
}

View file

@ -2,6 +2,7 @@ package valkey
import (
"encoding/json"
"errors"
"os"
"testing"
@ -45,3 +46,86 @@ func TestImpl(t *testing.T) {
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)
}
}
})
}
}