mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-17 05:44:57 +00:00
feat(store/valkey): add Redis(R) Sentinel support
Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
@@ -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,89 @@ 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")
|
||||||
|
ErrSentinelPasswordRequired = errors.New("valkey.Sentinel: password 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"` // if not set, default to Anubis or anubis.ProductName.
|
||||||
|
Username string `json:"username,omitempty"` // if not set, ignored
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 s.Password == "" {
|
||||||
|
errs = append(errs, ErrSentinelPasswordRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -68,14 +132,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 +151,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: ErrSentinelPasswordRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user