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
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue