diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
index d8049f0c09..32e83ebde0 100644
--- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
+++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
@@ -660,18 +660,28 @@ type AuthServerStorageConfig struct {
}
// RedisStorageConfig configures Redis connection for auth server storage.
-// Exactly one of addr (standalone) or sentinelConfig (Sentinel) must be set.
+// Exactly one of addr or sentinelConfig must be set. Set clusterMode to true when
+// addr points to a Redis Cluster discovery endpoint (GCP Memorystore Cluster,
+// AWS ElastiCache cluster mode enabled).
//
-// +kubebuilder:validation:XValidation:rule="(self.addr.size() > 0) != has(self.sentinelConfig)",message="exactly one of addr (standalone) or sentinelConfig (Sentinel) must be set"
+// +kubebuilder:validation:XValidation:rule="(self.addr.size() > 0) != has(self.sentinelConfig)",message="exactly one of addr or sentinelConfig must be set"
+// +kubebuilder:validation:XValidation:rule="!self.clusterMode || self.addr.size() > 0",message="clusterMode requires addr to be set"
//
//nolint:lll // CEL validation rules exceed line length limit
type RedisStorageConfig struct {
- // Addr is the Redis server address for standalone mode (e.g., "host:port").
- // Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- // a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ // Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ // Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ // AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ // Mutually exclusive with sentinelConfig.
// +optional
Addr string `json:"addr,omitempty"`
+ // ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ // Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ // cluster mode enabled). Requires addr to be set.
+ // +optional
+ ClusterMode bool `json:"clusterMode,omitempty"`
+
// SentinelConfig holds Redis Sentinel configuration.
// Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr.
// +optional
diff --git a/cmd/thv-operator/pkg/controllerutil/authserver.go b/cmd/thv-operator/pkg/controllerutil/authserver.go
index 47670fb01e..177875da16 100644
--- a/cmd/thv-operator/pkg/controllerutil/authserver.go
+++ b/cmd/thv-operator/pkg/controllerutil/authserver.go
@@ -604,12 +604,11 @@ func buildStorageRunConfig(
return nil, fmt.Errorf("redis config is required when storage type is redis")
}
- if redisConfig.Addr == "" && redisConfig.SentinelConfig == nil {
- return nil, fmt.Errorf("either addr (standalone) or sentinel config is required for Redis storage")
- }
-
if redisConfig.Addr != "" && redisConfig.SentinelConfig != nil {
- return nil, fmt.Errorf("addr and sentinel config are mutually exclusive for Redis storage")
+ return nil, fmt.Errorf("addr and sentinelConfig are mutually exclusive for Redis storage")
+ }
+ if redisConfig.Addr == "" && redisConfig.SentinelConfig == nil {
+ return nil, fmt.Errorf("one of addr (standalone or cluster) or sentinelConfig (Sentinel) is required for Redis storage")
}
if redisConfig.ACLUserConfig == nil ||
@@ -629,6 +628,7 @@ func buildStorageRunConfig(
rc := &storage.RedisRunConfig{
Addr: redisConfig.Addr,
+ ClusterMode: redisConfig.ClusterMode,
AuthType: storage.AuthTypeACLUser,
ACLUserConfig: aclRunConfig,
KeyPrefix: keyPrefix,
diff --git a/cmd/thv-operator/pkg/controllerutil/authserver_test.go b/cmd/thv-operator/pkg/controllerutil/authserver_test.go
index 48e60d5c89..c8cda64f38 100644
--- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go
+++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go
@@ -1980,7 +1980,7 @@ func TestBuildStorageRunConfig(t *testing.T) {
errContains: "redis config is required",
},
{
- name: "Redis storage missing both addr and sentinelConfig returns error",
+ name: "Redis storage missing addr and sentinelConfig returns error",
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
Issuer: "https://auth.example.com",
Storage: &mcpv1beta1.AuthServerStorageConfig{
@@ -1994,7 +1994,7 @@ func TestBuildStorageRunConfig(t *testing.T) {
},
},
wantErr: true,
- errContains: "either addr (standalone) or sentinel config is required",
+ errContains: "one of addr (standalone or cluster) or sentinelConfig (Sentinel) is required",
},
{
name: "Redis storage with both addr and sentinelConfig returns error",
@@ -2016,7 +2016,37 @@ func TestBuildStorageRunConfig(t *testing.T) {
},
},
wantErr: true,
- errContains: "addr and sentinel config are mutually exclusive",
+ errContains: "mutually exclusive",
+ },
+ {
+ name: "Redis cluster mode builds correctly",
+ authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
+ Issuer: "https://auth.example.com",
+ Storage: &mcpv1beta1.AuthServerStorageConfig{
+ Type: mcpv1beta1.AuthServerStorageTypeRedis,
+ Redis: &mcpv1beta1.RedisStorageConfig{
+ Addr: "discovery.example.com:6379",
+ ClusterMode: true,
+ ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{
+ UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "username"},
+ PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"},
+ },
+ },
+ },
+ },
+ checkFunc: func(t *testing.T, cfg *storage.RunConfig) {
+ t.Helper()
+ assert.Equal(t, string(storage.TypeRedis), cfg.Type)
+ require.NotNil(t, cfg.RedisConfig)
+ assert.Equal(t, "discovery.example.com:6379", cfg.RedisConfig.Addr)
+ assert.True(t, cfg.RedisConfig.ClusterMode)
+ assert.Nil(t, cfg.RedisConfig.SentinelConfig)
+ assert.Equal(t, storage.AuthTypeACLUser, cfg.RedisConfig.AuthType)
+ require.NotNil(t, cfg.RedisConfig.ACLUserConfig)
+ assert.Equal(t, authrunner.RedisUsernameEnvVar, cfg.RedisConfig.ACLUserConfig.UsernameEnvVar)
+ assert.Equal(t, authrunner.RedisPasswordEnvVar, cfg.RedisConfig.ACLUserConfig.PasswordEnvVar)
+ assert.Equal(t, "thv:auth:{default:test-server}:", cfg.RedisConfig.KeyPrefix)
+ },
},
{
name: "Redis storage with standalone addr builds correctly",
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index 13004927dc..faa443412e 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -316,10 +316,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -442,9 +449,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
@@ -1478,10 +1486,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -1604,9 +1619,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
index 3b1a72d21e..d752a5a546 100644
--- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -189,10 +189,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -315,9 +322,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
@@ -2799,10 +2807,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -2925,9 +2940,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
index e8e5c0c340..a8197b955f 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml
@@ -319,10 +319,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -445,9 +452,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
@@ -1481,10 +1489,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -1607,9 +1622,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
index a1e0d000c6..1aeaa8fdfa 100644
--- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
+++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_virtualmcpservers.yaml
@@ -192,10 +192,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -318,9 +325,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
@@ -2802,10 +2810,17 @@ spec:
type: object
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
- Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
- a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
+ Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
+ AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
+ Mutually exclusive with sentinelConfig.
type: string
+ clusterMode:
+ description: |-
+ ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
+ Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
+ cluster mode enabled). Requires addr to be set.
+ type: boolean
dialTimeout:
default: 5s
description: |-
@@ -2928,9 +2943,10 @@ spec:
- aclUserConfig
type: object
x-kubernetes-validations:
- - message: exactly one of addr (standalone) or sentinelConfig
- (Sentinel) must be set
+ - message: exactly one of addr or sentinelConfig must be set
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
+ - message: clusterMode requires addr to be set
+ rule: '!self.clusterMode || self.addr.size() > 0'
type:
default: memory
description: |-
diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md
index 24f5239a4b..010b399bde 100644
--- a/docs/operator/crd-api.md
+++ b/docs/operator/crd-api.md
@@ -2859,7 +2859,9 @@ _Appears in:_
RedisStorageConfig configures Redis connection for auth server storage.
-Exactly one of addr (standalone) or sentinelConfig (Sentinel) must be set.
+Exactly one of addr or sentinelConfig must be set. Set clusterMode to true when
+addr points to a Redis Cluster discovery endpoint (GCP Memorystore Cluster,
+AWS ElastiCache cluster mode enabled).
@@ -2868,7 +2870,8 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
-| `addr` _string_ | Addr is the Redis server address for standalone mode (e.g., "host:port").
Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig. | | Optional: \{\}
|
+| `addr` _string_ | Addr is the Redis server address (host:port). Required for standalone and cluster modes.
Use for managed Redis services that expose a single endpoint (GCP Memorystore basic tier,
AWS ElastiCache without cluster mode, or cluster-mode services when clusterMode is true).
Mutually exclusive with sentinelConfig. | | Optional: \{\}
|
+| `clusterMode` _boolean_ | ClusterMode enables the Redis Cluster protocol. Set to true when addr points to a
Redis Cluster discovery endpoint (e.g., GCP Memorystore Cluster, AWS ElastiCache
cluster mode enabled). Requires addr to be set. | | Optional: \{\}
|
| `sentinelConfig` _[api.v1beta1.RedisSentinelConfig](#apiv1beta1redissentinelconfig)_ | SentinelConfig holds Redis Sentinel configuration.
Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr. | | Optional: \{\}
|
| `aclUserConfig` _[api.v1beta1.RedisACLUserConfig](#apiv1beta1redisacluserconfig)_ | ACLUserConfig configures Redis ACL user authentication. | | Required: \{\}
|
| `dialTimeout` _string_ | DialTimeout is the timeout for establishing connections.
Format: Go duration string (e.g., "5s", "1m"). | 5s | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
|
diff --git a/docs/server/docs.go b/docs/server/docs.go
index a108f6087f..1cd3a245da 100644
--- a/docs/server/docs.go
+++ b/docs/server/docs.go
@@ -769,13 +769,17 @@ const docTemplate = `{
"$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_storage.ACLUserRunConfig"
},
"addr": {
- "description": "Addr is the Redis server address for standalone mode (e.g., \"host:port\").\nMutually exclusive with SentinelConfig.",
+ "description": "Addr is the Redis server address (host:port). Required for standalone and cluster modes.\nMutually exclusive with SentinelConfig.",
"type": "string"
},
"auth_type": {
"description": "AuthType must be \"aclUser\" - only ACL user authentication is supported.",
"type": "string"
},
+ "cluster_mode": {
+ "description": "ClusterMode enables the Redis Cluster protocol. Requires Addr to be set.",
+ "type": "boolean"
+ },
"dial_timeout": {
"description": "DialTimeout is the timeout for establishing connections (e.g., \"5s\").",
"type": "string"
diff --git a/docs/server/swagger.json b/docs/server/swagger.json
index 8a5d8df9b5..2fa20abe2d 100644
--- a/docs/server/swagger.json
+++ b/docs/server/swagger.json
@@ -762,13 +762,17 @@
"$ref": "#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_storage.ACLUserRunConfig"
},
"addr": {
- "description": "Addr is the Redis server address for standalone mode (e.g., \"host:port\").\nMutually exclusive with SentinelConfig.",
+ "description": "Addr is the Redis server address (host:port). Required for standalone and cluster modes.\nMutually exclusive with SentinelConfig.",
"type": "string"
},
"auth_type": {
"description": "AuthType must be \"aclUser\" - only ACL user authentication is supported.",
"type": "string"
},
+ "cluster_mode": {
+ "description": "ClusterMode enables the Redis Cluster protocol. Requires Addr to be set.",
+ "type": "boolean"
+ },
"dial_timeout": {
"description": "DialTimeout is the timeout for establishing connections (e.g., \"5s\").",
"type": "string"
diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml
index bdc1cac8a0..46fa4b74a4 100644
--- a/docs/server/swagger.yaml
+++ b/docs/server/swagger.yaml
@@ -820,13 +820,17 @@ components:
$ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_storage.ACLUserRunConfig'
addr:
description: |-
- Addr is the Redis server address for standalone mode (e.g., "host:port").
+ Addr is the Redis server address (host:port). Required for standalone and cluster modes.
Mutually exclusive with SentinelConfig.
type: string
auth_type:
description: AuthType must be "aclUser" - only ACL user authentication is
supported.
type: string
+ cluster_mode:
+ description: ClusterMode enables the Redis Cluster protocol. Requires Addr
+ to be set.
+ type: boolean
dial_timeout:
description: DialTimeout is the timeout for establishing connections (e.g.,
"5s").
diff --git a/pkg/authserver/runner/embeddedauthserver.go b/pkg/authserver/runner/embeddedauthserver.go
index c01d55c34a..547d04babd 100644
--- a/pkg/authserver/runner/embeddedauthserver.go
+++ b/pkg/authserver/runner/embeddedauthserver.go
@@ -561,19 +561,22 @@ func convertRedisRunConfig(rc *storage.RedisRunConfig) (*storage.RedisConfig, er
}
if rc.Addr != "" && rc.SentinelConfig != nil {
- return nil, fmt.Errorf("addr and sentinel_config are mutually exclusive")
+ return nil, fmt.Errorf("addr and sentinel_config are mutually exclusive; exactly one must be set")
}
if rc.Addr == "" && rc.SentinelConfig == nil {
- return nil, fmt.Errorf("one of addr (standalone) or sentinel_config (sentinel) is required")
+ return nil, fmt.Errorf("one of addr (standalone or cluster) or sentinel_config (sentinel) is required")
+ }
+ if rc.ClusterMode && rc.SentinelConfig != nil {
+ return nil, fmt.Errorf("cluster mode cannot be used with sentinel configuration")
}
cfg := &storage.RedisConfig{
- KeyPrefix: rc.KeyPrefix,
+ Addr: rc.Addr,
+ ClusterMode: rc.ClusterMode,
+ KeyPrefix: rc.KeyPrefix,
}
- if rc.Addr != "" {
- cfg.Addr = rc.Addr
- } else {
+ if rc.SentinelConfig != nil {
cfg.SentinelConfig = &storage.SentinelConfig{
MasterName: rc.SentinelConfig.MasterName,
SentinelAddrs: rc.SentinelConfig.SentinelAddrs,
diff --git a/pkg/authserver/runner/embeddedauthserver_test.go b/pkg/authserver/runner/embeddedauthserver_test.go
index 0ae2a20375..d8ef5631c1 100644
--- a/pkg/authserver/runner/embeddedauthserver_test.go
+++ b/pkg/authserver/runner/embeddedauthserver_test.go
@@ -1102,7 +1102,7 @@ func TestCreateStorage(t *testing.T) {
},
})
require.Error(t, err)
- assert.Contains(t, err.Error(), "one of addr (standalone) or sentinel_config (sentinel) is required")
+ assert.Contains(t, err.Error(), "one of addr (standalone or cluster) or sentinel_config (sentinel) is required")
})
}
@@ -1126,7 +1126,7 @@ func TestConvertRedisRunConfig(t *testing.T) {
},
})
require.Error(t, err)
- assert.Contains(t, err.Error(), "one of addr (standalone) or sentinel_config (sentinel) is required")
+ assert.Contains(t, err.Error(), "one of addr (standalone or cluster) or sentinel_config (sentinel) is required")
})
t.Run("missing ACL user config returns error", func(t *testing.T) {
@@ -1189,6 +1189,23 @@ func TestConvertRedisRunConfig(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "one of addr")
})
+
+ t.Run("cluster mode with sentinel also set returns error", func(t *testing.T) {
+ t.Parallel()
+ _, err := convertRedisRunConfig(&storage.RedisRunConfig{
+ ClusterMode: true,
+ SentinelConfig: &storage.SentinelRunConfig{
+ MasterName: "mymaster",
+ SentinelAddrs: []string{"sentinel:26379"},
+ },
+ ACLUserConfig: &storage.ACLUserRunConfig{
+ PasswordEnvVar: "PASS",
+ },
+ KeyPrefix: "thv:",
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "cluster mode cannot be used with sentinel")
+ })
}
// TestConvertRedisRunConfig_WithEnvVars tests convertRedisRunConfig with environment variables.
@@ -1303,6 +1320,30 @@ func TestConvertRedisRunConfig_WithEnvVars(t *testing.T) {
assert.Empty(t, cfg.ACLUserConfig.Username)
assert.Equal(t, "mypass", cfg.ACLUserConfig.Password)
})
+
+ t.Run("cluster mode resolves correctly", func(t *testing.T) {
+ t.Setenv("TEST_REDIS_USER_CLUSTER", "clusteruser")
+ t.Setenv("TEST_REDIS_PASS_CLUSTER", "clusterpass")
+
+ cfg, err := convertRedisRunConfig(&storage.RedisRunConfig{
+ Addr: "discovery.example.com:6379",
+ ClusterMode: true,
+ ACLUserConfig: &storage.ACLUserRunConfig{
+ UsernameEnvVar: "TEST_REDIS_USER_CLUSTER",
+ PasswordEnvVar: "TEST_REDIS_PASS_CLUSTER",
+ },
+ KeyPrefix: "thv:auth:ns:name:",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ assert.Equal(t, "discovery.example.com:6379", cfg.Addr)
+ assert.True(t, cfg.ClusterMode)
+ assert.Nil(t, cfg.SentinelConfig)
+ require.NotNil(t, cfg.ACLUserConfig)
+ assert.Equal(t, "clusteruser", cfg.ACLUserConfig.Username)
+ assert.Equal(t, "clusterpass", cfg.ACLUserConfig.Password)
+ assert.Equal(t, "thv:auth:ns:name:", cfg.KeyPrefix)
+ })
}
// stubServer is a minimal authserver.Server implementation for testing RegisterHandlers.
diff --git a/pkg/authserver/storage/config.go b/pkg/authserver/storage/config.go
index 2f066b0a98..281be69866 100644
--- a/pkg/authserver/storage/config.go
+++ b/pkg/authserver/storage/config.go
@@ -67,12 +67,16 @@ type RunConfig struct {
}
// RedisRunConfig is the serializable Redis configuration for RunConfig.
-// Exactly one of Addr (standalone) or SentinelConfig (Sentinel) must be set.
+// Exactly one of Addr (standalone/cluster) or SentinelConfig must be set.
+// Set ClusterMode to true when Addr points to a Redis Cluster discovery endpoint.
type RedisRunConfig struct {
- // Addr is the Redis server address for standalone mode (e.g., "host:port").
+ // Addr is the Redis server address (host:port). Required for standalone and cluster modes.
// Mutually exclusive with SentinelConfig.
Addr string `json:"addr,omitempty" yaml:"addr,omitempty"`
+ // ClusterMode enables the Redis Cluster protocol. Requires Addr to be set.
+ ClusterMode bool `json:"cluster_mode,omitempty" yaml:"cluster_mode,omitempty"`
+
// SentinelConfig contains Sentinel-specific configuration.
// Mutually exclusive with Addr.
SentinelConfig *SentinelRunConfig `json:"sentinel_config,omitempty" yaml:"sentinel_config,omitempty"`
@@ -95,7 +99,7 @@ type RedisRunConfig struct {
// WriteTimeout is the timeout for write operations (e.g., "3s").
WriteTimeout string `json:"write_timeout,omitempty" yaml:"write_timeout,omitempty"`
- // TLS configures TLS for Redis/Valkey master connections.
+ // TLS configures TLS for Redis/Valkey master or cluster node connections.
TLS *RedisTLSRunConfig `json:"tls,omitempty" yaml:"tls,omitempty"`
// SentinelTLS configures TLS for Sentinel connections. Only applies when SentinelConfig is set.
diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go
index 9b968be27c..6b3fe4f19a 100644
--- a/pkg/authserver/storage/redis.go
+++ b/pkg/authserver/storage/redis.go
@@ -52,10 +52,14 @@ func warnOnCleanupErr(err error, operation, key string) {
// RedisConfig holds Redis connection configuration for runtime use.
type RedisConfig struct {
- // Addr is the Redis server address for standalone mode (e.g., "host:port").
+ // Addr is the Redis server address (host:port). Required for standalone and cluster modes.
// Mutually exclusive with SentinelConfig.
Addr string
+ // ClusterMode enables Redis Cluster protocol. Requires Addr to be set.
+ // Use for managed cluster-mode services (GCP Memorystore Cluster, AWS ElastiCache cluster mode).
+ ClusterMode bool
+
// SentinelConfig is required for Sentinel mode. Mutually exclusive with Addr.
SentinelConfig *SentinelConfig
@@ -70,8 +74,8 @@ type RedisConfig struct {
ReadTimeout time.Duration
WriteTimeout time.Duration
- // TLS configures TLS for connections to the Redis/Valkey master.
- // When nil, master connections are plaintext.
+ // TLS configures TLS for connections to the Redis/Valkey master or cluster nodes.
+ // When nil, connections are plaintext.
TLS *RedisTLSConfig
// SentinelTLS configures TLS for connections to Sentinel instances.
@@ -105,9 +109,9 @@ type ACLUserConfig struct {
}
// RedisStorage implements the Storage interface backed by Redis.
-// Supports standalone mode (single endpoint) and Sentinel failover mode.
-// It provides distributed storage for OAuth2 tokens, authorization codes,
-// user data, and pending authorizations, enabling horizontal scaling.
+// Supports standalone mode (single endpoint), Sentinel failover mode, and
+// Cluster mode. It provides distributed storage for OAuth2 tokens, authorization
+// codes, user data, and pending authorizations, enabling horizontal scaling.
type RedisStorage struct {
client redis.UniversalClient
keyPrefix string
@@ -206,7 +210,7 @@ func configureTLSDialer(opts *redis.FailoverOptions, masterCfg, sentinelCfg *Red
}
// NewRedisStorage creates Redis-backed storage.
-// Supports standalone mode (Addr set) and Sentinel failover mode (SentinelConfig set).
+// Supports standalone mode (Addr), cluster mode (Addr + ClusterMode), and Sentinel mode (SentinelConfig).
// Returns error if configuration validation fails or connection cannot be established.
func NewRedisStorage(ctx context.Context, cfg RedisConfig) (*RedisStorage, error) {
if err := validateConfig(&cfg); err != nil {
@@ -226,7 +230,8 @@ func NewRedisStorage(ctx context.Context, cfg RedisConfig) (*RedisStorage, error
var client redis.UniversalClient
- if cfg.SentinelConfig != nil {
+ switch {
+ case cfg.SentinelConfig != nil:
opts := &redis.FailoverOptions{
MasterName: cfg.SentinelConfig.MasterName,
SentinelAddrs: cfg.SentinelConfig.SentinelAddrs,
@@ -244,7 +249,26 @@ func NewRedisStorage(ctx context.Context, cfg RedisConfig) (*RedisStorage, error
}
client = redis.NewFailoverClient(opts)
- } else {
+
+ case cfg.ClusterMode:
+ tlsCfg, err := buildTLSConfig(cfg.TLS)
+ if err != nil {
+ return nil, fmt.Errorf("TLS config: %w", err)
+ }
+
+ opts := &redis.ClusterOptions{
+ Addrs: []string{cfg.Addr},
+ Username: cfg.ACLUserConfig.Username,
+ Password: cfg.ACLUserConfig.Password,
+ DialTimeout: cfg.DialTimeout,
+ ReadTimeout: cfg.ReadTimeout,
+ WriteTimeout: cfg.WriteTimeout,
+ TLSConfig: tlsCfg,
+ }
+
+ client = redis.NewClusterClient(opts)
+
+ default:
masterTLS, err := buildTLSConfig(cfg.TLS)
if err != nil {
return nil, fmt.Errorf("master TLS config: %w", err)
@@ -293,11 +317,17 @@ func defaultSessionFactory(subject, idpSessionID, clientID string) fosite.Sessio
}
func validateConfig(cfg *RedisConfig) error {
+ if cfg.ClusterMode && cfg.SentinelConfig != nil {
+ return errors.New("cluster mode cannot be used with sentinel configuration")
+ }
if cfg.Addr != "" && cfg.SentinelConfig != nil {
- return errors.New("addr and sentinel configuration are mutually exclusive")
+ return errors.New("addr and sentinel configuration are mutually exclusive; exactly one must be set")
+ }
+ if cfg.ClusterMode && cfg.Addr == "" {
+ return errors.New("cluster mode requires addr to be set")
}
if cfg.Addr == "" && cfg.SentinelConfig == nil {
- return errors.New("one of addr (standalone) or sentinel configuration is required")
+ return errors.New("one of addr (standalone or cluster) or sentinel configuration is required")
}
if cfg.SentinelConfig != nil {
if cfg.SentinelConfig.MasterName == "" {
diff --git a/pkg/authserver/storage/redis_test.go b/pkg/authserver/storage/redis_test.go
index 25fb3d6e32..bb7a75a6bf 100644
--- a/pkg/authserver/storage/redis_test.go
+++ b/pkg/authserver/storage/redis_test.go
@@ -103,7 +103,7 @@ func TestRedisConfig_Validation(t *testing.T) {
{
name: "neither addr nor sentinel config",
cfg: RedisConfig{ACLUserConfig: &ACLUserConfig{Username: "u", Password: "p"}, KeyPrefix: "test:"},
- wantErr: "one of addr (standalone) or sentinel configuration is required",
+ wantErr: "one of addr (standalone or cluster) or sentinel configuration is required",
},
{
name: "addr and sentinel config both set",
@@ -113,7 +113,26 @@ func TestRedisConfig_Validation(t *testing.T) {
ACLUserConfig: &ACLUserConfig{Username: "u", Password: "p"},
KeyPrefix: "test:",
},
- wantErr: "addr and sentinel configuration are mutually exclusive",
+ wantErr: "mutually exclusive",
+ },
+ {
+ name: "cluster mode with sentinel config",
+ cfg: RedisConfig{
+ ClusterMode: true,
+ SentinelConfig: &SentinelConfig{MasterName: "m", SentinelAddrs: []string{"localhost:26379"}},
+ ACLUserConfig: &ACLUserConfig{Username: "u", Password: "p"},
+ KeyPrefix: "test:",
+ },
+ wantErr: "cluster mode cannot be used with sentinel",
+ },
+ {
+ name: "cluster mode without addr",
+ cfg: RedisConfig{
+ ClusterMode: true,
+ ACLUserConfig: &ACLUserConfig{Username: "u", Password: "p"},
+ KeyPrefix: "test:",
+ },
+ wantErr: "cluster mode requires addr",
},
{
name: "missing sentinel master name",
@@ -222,6 +241,28 @@ func TestNewRedisStorage_Standalone_WithMiniredis(t *testing.T) {
require.NoError(t, s.Health(ctx))
}
+func TestNewRedisStorage_Cluster_ConnectionFailure(t *testing.T) {
+ t.Parallel()
+
+ cfg := RedisConfig{
+ Addr: "localhost:19998",
+ ClusterMode: true,
+ ACLUserConfig: &ACLUserConfig{
+ Username: "user",
+ Password: "pass",
+ },
+ KeyPrefix: "test:",
+ DialTimeout: 100 * time.Millisecond,
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancel()
+
+ _, err := NewRedisStorage(ctx, cfg)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to connect to redis")
+}
+
// --- Client Tests ---
func TestRedisStorage_Client(t *testing.T) {