diff --git a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go index fb24749204..61ef7a55f7 100644 --- a/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go +++ b/cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go @@ -525,18 +525,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 bccf0df3bb..5b366c36ee 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver.go @@ -544,12 +544,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 || @@ -569,6 +568,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 7add4da7af..2def8c7118 100644 --- a/cmd/thv-operator/pkg/controllerutil/authserver_test.go +++ b/cmd/thv-operator/pkg/controllerutil/authserver_test.go @@ -1674,7 +1674,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{ @@ -1688,7 +1688,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", @@ -1710,7 +1710,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 6e4f0d6f65..3c79c0376f 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: |- @@ -1364,10 +1372,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: |- @@ -1490,9 +1505,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 a51fe4b5bd..3bf7d667b9 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: |- @@ -2685,10 +2693,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: |- @@ -2811,9 +2826,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 4ea0c1cb07..d797872318 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: |- @@ -1367,10 +1375,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: |- @@ -1493,9 +1508,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 6078670479..21321d0c9e 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: |- @@ -2688,10 +2696,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: |- @@ -2814,9 +2829,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 e60f05015e..1fcaf32684 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -2803,7 +2803,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). @@ -2812,7 +2814,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) {