Skip to content

Commit 02989f0

Browse files
authored
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>
1 parent 69e9023 commit 02989f0

7 files changed

Lines changed: 321 additions & 24 deletions

File tree

.github/actions/spelling/allow.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ msgbox
88
xeact
99
ABee
1010
tencent
11-
maintnotifications
11+
maintnotifications
12+
azurediamond

.github/actions/spelling/expect.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ licstart
200200
lightpanda
201201
limsa
202202
Linting
203+
listor
203204
LLU
204205
loadbalancer
205206
lol
@@ -217,6 +218,10 @@ mnt
217218
Mojeek
218219
mojeekbot
219220
mozilla
221+
myclient
222+
mymaster
223+
mypass
224+
myuser
220225
nbf
221226
nepeat
222227
netsurf
@@ -267,7 +272,6 @@ qwantbot
267272
rac
268273
rawler
269274
rcvar
270-
rdb
271275
redhat
272276
redir
273277
redirectscheme

docs/docs/admin/policies.mdx

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,10 @@ Using this backend will cause a lot of S3 operations, at least one for creating
225225

226226
The `s3api` backend takes the following configuration options:
227227

228-
| Name | Type | Example | Description |
229-
| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
230-
| `bucketName` | string | The name of the dedicated bucket for Anubis to store information in. |
231-
| `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. |
228+
| Name | Type | Example | Description |
229+
| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
230+
| `bucketName` | string | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in. |
231+
| `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. |
232232

233233
:::note
234234

@@ -279,7 +279,7 @@ store:
279279

280280
:::note
281281

282-
You can also use [Redis](http://redis.io/) with Anubis.
282+
You can also use [Redis](http://redis.io/) with Anubis.
283283

284284
:::
285285

@@ -291,15 +291,17 @@ This backend is ideal if you are running multiple instances of Anubis in a worke
291291
| Does your service get a lot of traffic? | ✅ Yes |
292292
| Do you want to store data persistently when Anubis restarts? | ✅ Yes |
293293
| Do you run Anubis without mutable filesystem storage? | ✅ Yes |
294-
| Do you have Redis or Valkey installed? | ✅ Yes |
294+
| Do you have Redis or Valkey installed? | ✅ Yes |
295295

296296
#### Configuration
297297

298298
The `valkey` backend takes the following configuration options:
299299

300-
| Name | Type | Example | Description |
301-
| :---- | :----- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- |
302-
| `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. |
300+
| Name | Type | Example | Description |
301+
| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
302+
| `cluster` | bool | `false` | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data. |
303+
| `sentinel` | object | `{}` | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples |
304+
| `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. |
303305

304306
Example:
305307

@@ -314,6 +316,18 @@ store:
314316

315317
This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).
316318

319+
#### Redis™ Sentinel
320+
321+
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:
322+
323+
| Name | Type | Example | Description |
324+
| :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
325+
| `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. |
326+
| `clientName` | string | `Anubis` | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel. |
327+
| `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. |
328+
| `username` | string | `azurediamond` | The username used to authenticate against the Redis™ Sentinel and Redis™ servers. |
329+
| `password` | string | `hunter2` | The password used to authenticate against the Redis™ Sentinel and Redis™ servers. |
330+
317331
## Risk calculation for downstream services
318332

319333
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:

internal/listor.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package internal
2+
3+
import (
4+
"encoding/json"
5+
)
6+
7+
// ListOr[T any] is a slice that can contain either a single T or multiple T values.
8+
// During JSON unmarshaling, it checks if the first character is '[' to determine
9+
// whether to treat the JSON as an array or a single value.
10+
type ListOr[T any] []T
11+
12+
func (lo *ListOr[T]) UnmarshalJSON(data []byte) error {
13+
if len(data) == 0 {
14+
return nil
15+
}
16+
17+
// Check if first non-whitespace character is '['
18+
firstChar := data[0]
19+
for i := 0; i < len(data); i++ {
20+
if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' {
21+
firstChar = data[i]
22+
break
23+
}
24+
}
25+
26+
if firstChar == '[' {
27+
// It's an array, unmarshal directly
28+
return json.Unmarshal(data, (*[]T)(lo))
29+
} else {
30+
// It's a single value, unmarshal as a single item in a slice
31+
var single T
32+
if err := json.Unmarshal(data, &single); err != nil {
33+
return err
34+
}
35+
*lo = ListOr[T]{single}
36+
}
37+
38+
return nil
39+
}

internal/listor_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package internal
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestListOr_UnmarshalJSON(t *testing.T) {
9+
t.Run("single value should be unmarshaled as single item", func(t *testing.T) {
10+
var lo ListOr[string]
11+
12+
err := json.Unmarshal([]byte(`"hello"`), &lo)
13+
if err != nil {
14+
t.Fatalf("Failed to unmarshal single string: %v", err)
15+
}
16+
17+
if len(lo) != 1 {
18+
t.Fatalf("Expected 1 item, got %d", len(lo))
19+
}
20+
21+
if lo[0] != "hello" {
22+
t.Errorf("Expected 'hello', got %q", lo[0])
23+
}
24+
})
25+
26+
t.Run("array should be unmarshaled as multiple items", func(t *testing.T) {
27+
var lo ListOr[string]
28+
29+
err := json.Unmarshal([]byte(`["hello", "world"]`), &lo)
30+
if err != nil {
31+
t.Fatalf("Failed to unmarshal array: %v", err)
32+
}
33+
34+
if len(lo) != 2 {
35+
t.Fatalf("Expected 2 items, got %d", len(lo))
36+
}
37+
38+
if lo[0] != "hello" {
39+
t.Errorf("Expected 'hello', got %q", lo[0])
40+
}
41+
if lo[1] != "world" {
42+
t.Errorf("Expected 'world', got %q", lo[1])
43+
}
44+
})
45+
46+
t.Run("single number should be unmarshaled as single item", func(t *testing.T) {
47+
var lo ListOr[int]
48+
49+
err := json.Unmarshal([]byte(`42`), &lo)
50+
if err != nil {
51+
t.Fatalf("Failed to unmarshal single number: %v", err)
52+
}
53+
54+
if len(lo) != 1 {
55+
t.Fatalf("Expected 1 item, got %d", len(lo))
56+
}
57+
58+
if lo[0] != 42 {
59+
t.Errorf("Expected 42, got %d", lo[0])
60+
}
61+
})
62+
63+
t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) {
64+
var lo ListOr[int]
65+
66+
err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo)
67+
if err != nil {
68+
t.Fatalf("Failed to unmarshal number array: %v", err)
69+
}
70+
71+
if len(lo) != 3 {
72+
t.Fatalf("Expected 3 items, got %d", len(lo))
73+
}
74+
75+
if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 {
76+
t.Errorf("Expected [1, 2, 3], got %v", lo)
77+
}
78+
})
79+
}

lib/store/valkey/factory.go

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"time"
99

10+
"github.com/TecharoHQ/anubis/internal"
1011
"github.com/TecharoHQ/anubis/lib/store"
1112
valkey "github.com/redis/go-redis/v9"
1213
"github.com/redis/go-redis/v9/maintnotifications"
@@ -16,26 +17,84 @@ func init() {
1617
store.Register("valkey", Factory{})
1718
}
1819

19-
// Errors kept as-is so other code/tests still pass.
2020
var (
2121
ErrNoURL = errors.New("valkey.Config: no URL defined")
2222
ErrBadURL = errors.New("valkey.Config: URL is invalid")
23+
24+
// Sentinel validation errors
25+
ErrSentinelMasterNameRequired = errors.New("valkey.Sentinel: masterName is required")
26+
ErrSentinelAddrRequired = errors.New("valkey.Sentinel: addr is required")
27+
ErrSentinelAddrEmpty = errors.New("valkey.Sentinel: addr cannot be empty")
2328
)
2429

2530
// Config is what Anubis unmarshals from the "parameters" JSON.
2631
type Config struct {
2732
URL string `json:"url"`
2833
Cluster bool `json:"cluster,omitempty"`
34+
35+
Sentinel *Sentinel `json:"sentinel,omitempty"`
2936
}
3037

3138
func (c Config) Valid() error {
32-
if c.URL == "" {
33-
return ErrNoURL
39+
var errs []error
40+
41+
if c.URL == "" && c.Sentinel == nil {
42+
errs = append(errs, ErrNoURL)
43+
}
44+
45+
// Validate URL only if provided
46+
if c.URL != "" {
47+
if _, err := valkey.ParseURL(c.URL); err != nil {
48+
errs = append(errs, fmt.Errorf("%w: %v", ErrBadURL, err))
49+
}
50+
}
51+
52+
if c.Sentinel != nil {
53+
if err := c.Sentinel.Valid(); err != nil {
54+
errs = append(errs, err)
55+
}
56+
}
57+
58+
if len(errs) > 0 {
59+
return errors.Join(errs...)
60+
}
61+
62+
return nil
63+
}
64+
65+
type Sentinel struct {
66+
MasterName string `json:"masterName"`
67+
Addr internal.ListOr[string] `json:"addr"`
68+
ClientName string `json:"clientName,omitempty"`
69+
Username string `json:"username,omitempty"`
70+
Password string `json:"password,omitempty"`
71+
}
72+
73+
func (s Sentinel) Valid() error {
74+
var errs []error
75+
76+
if s.MasterName == "" {
77+
errs = append(errs, ErrSentinelMasterNameRequired)
78+
}
79+
80+
if len(s.Addr) == 0 {
81+
errs = append(errs, ErrSentinelAddrRequired)
82+
} else {
83+
// Check if all addresses in the list are empty
84+
allEmpty := true
85+
for _, addr := range s.Addr {
86+
if addr != "" {
87+
allEmpty = false
88+
break
89+
}
90+
}
91+
if allEmpty {
92+
errs = append(errs, ErrSentinelAddrEmpty)
93+
}
3494
}
3595

36-
// Just validate that it's a valid Redis URL.
37-
if _, err := valkey.ParseURL(c.URL); err != nil {
38-
return fmt.Errorf("%w: %v", ErrBadURL, err)
96+
if len(errs) > 0 {
97+
return errors.Join(errs...)
3998
}
4099

41100
return nil
@@ -68,14 +127,15 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
68127
return nil, err
69128
}
70129

71-
opts, err := valkey.ParseURL(cfg.URL)
72-
if err != nil {
73-
return nil, fmt.Errorf("valkey.Factory: %w", err)
74-
}
75-
76130
var client redisClient
77131

78-
if cfg.Cluster {
132+
switch {
133+
case cfg.Cluster:
134+
opts, err := valkey.ParseURL(cfg.URL)
135+
if err != nil {
136+
return nil, fmt.Errorf("valkey.Factory: %w", err)
137+
}
138+
79139
// Cluster mode: use the parsed Addr as the seed node.
80140
clusterOpts := &valkey.ClusterOptions{
81141
Addrs: []string{opts.Addr},
@@ -86,7 +146,23 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
86146
},
87147
}
88148
client = valkey.NewClusterClient(clusterOpts)
89-
} else {
149+
case cfg.Sentinel != nil:
150+
opts := &valkey.FailoverOptions{
151+
MasterName: cfg.Sentinel.MasterName,
152+
SentinelAddrs: cfg.Sentinel.Addr,
153+
SentinelUsername: cfg.Sentinel.Username,
154+
SentinelPassword: cfg.Sentinel.Password,
155+
Username: cfg.Sentinel.Username,
156+
Password: cfg.Sentinel.Password,
157+
ClientName: cfg.Sentinel.ClientName,
158+
}
159+
client = valkey.NewFailoverClusterClient(opts)
160+
default:
161+
opts, err := valkey.ParseURL(cfg.URL)
162+
if err != nil {
163+
return nil, fmt.Errorf("valkey.Factory: %w", err)
164+
}
165+
90166
opts.MaintNotificationsConfig = &maintnotifications.Config{
91167
Mode: maintnotifications.ModeDisabled,
92168
}

0 commit comments

Comments
 (0)