Skip to content

Commit d105775

Browse files
reyortiz3claude
andcommitted
Add Redis Cluster mode support to auth server storage
Managed Redis services like GCP Memorystore Cluster and AWS ElastiCache Serverless use the Redis Cluster protocol rather than standalone or Sentinel connections, making it impossible to use them without this third mode. - Add ClusterConfig/ClusterRunConfig types to storage and runner layers - Wire cluster client creation via redis.NewClusterClient in NewRedisStorage - Update validateConfig, convertRedisRunConfig, and buildStorageRunConfig to enforce three-way mutual exclusion (addr / sentinel / cluster) - Add RedisClusterConfig CRD type with MinItems=1 validation; update CEL rule to require exactly one of the three modes - Regenerate deepcopy, CRD YAML, and Helm chart templates - Add unit tests for all new validation and happy-path code paths Closes #5010 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dc74588 commit d105775

13 files changed

Lines changed: 554 additions & 80 deletions

cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -525,23 +525,29 @@ type AuthServerStorageConfig struct {
525525
}
526526

527527
// RedisStorageConfig configures Redis connection for auth server storage.
528-
// Exactly one of addr (standalone) or sentinelConfig (Sentinel) must be set.
528+
// Exactly one of addr (standalone), sentinelConfig (Sentinel), or clusterConfig (Cluster) must be set.
529529
//
530-
// +kubebuilder:validation:XValidation:rule="(self.addr.size() > 0) != has(self.sentinelConfig)",message="exactly one of addr (standalone) or sentinelConfig (Sentinel) must be set"
530+
// +kubebuilder:validation:XValidation:rule="(self.addr.size() > 0 ? 1 : 0) + (has(self.sentinelConfig) ? 1 : 0) + (has(self.clusterConfig) ? 1 : 0) == 1",message="exactly one of addr (standalone), sentinelConfig (Sentinel), or clusterConfig (Cluster) must be set"
531531
//
532532
//nolint:lll // CEL validation rules exceed line length limit
533533
type RedisStorageConfig struct {
534534
// Addr is the Redis server address for standalone mode (e.g., "host:port").
535535
// Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
536-
// a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
536+
// a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig and clusterConfig.
537537
// +optional
538538
Addr string `json:"addr,omitempty"`
539539

540540
// SentinelConfig holds Redis Sentinel configuration.
541-
// Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr.
541+
// Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr and clusterConfig.
542542
// +optional
543543
SentinelConfig *RedisSentinelConfig `json:"sentinelConfig,omitempty"`
544544

545+
// ClusterConfig holds Redis Cluster configuration.
546+
// Use for managed Redis services that use the Redis Cluster protocol (e.g., GCP Memorystore Cluster,
547+
// AWS ElastiCache Serverless). Mutually exclusive with addr and sentinelConfig.
548+
// +optional
549+
ClusterConfig *RedisClusterConfig `json:"clusterConfig,omitempty"`
550+
545551
// ACLUserConfig configures Redis ACL user authentication.
546552
// +kubebuilder:validation:Required
547553
ACLUserConfig *RedisACLUserConfig `json:"aclUserConfig"`
@@ -601,6 +607,16 @@ type RedisSentinelConfig struct {
601607
DB int32 `json:"db,omitempty"`
602608
}
603609

610+
// RedisClusterConfig configures Redis Cluster connection.
611+
type RedisClusterConfig struct {
612+
// Addrs is the list of seed node host:port addresses for the Redis Cluster.
613+
// At least one address is required; go-redis discovers other nodes automatically.
614+
// +kubebuilder:validation:Required
615+
// +kubebuilder:validation:MinItems=1
616+
// +listType=atomic
617+
Addrs []string `json:"addrs"`
618+
}
619+
604620
// SentinelServiceRef references a Kubernetes Service for Sentinel discovery.
605621
type SentinelServiceRef struct {
606622
// Name of the Sentinel Service.

cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/thv-operator/pkg/controllerutil/authserver.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -544,12 +544,8 @@ func buildStorageRunConfig(
544544
return nil, fmt.Errorf("redis config is required when storage type is redis")
545545
}
546546

547-
if redisConfig.Addr == "" && redisConfig.SentinelConfig == nil {
548-
return nil, fmt.Errorf("either addr (standalone) or sentinel config is required for Redis storage")
549-
}
550-
551-
if redisConfig.Addr != "" && redisConfig.SentinelConfig != nil {
552-
return nil, fmt.Errorf("addr and sentinel config are mutually exclusive for Redis storage")
547+
if err := validateRedisConnectionMode(redisConfig); err != nil {
548+
return nil, err
553549
}
554550

555551
if redisConfig.ACLUserConfig == nil ||
@@ -592,12 +588,41 @@ func buildStorageRunConfig(
592588
rc.SentinelTLS = convertRedisTLSConfig(redisConfig.SentinelTLS, true)
593589
}
594590

591+
if redisConfig.ClusterConfig != nil {
592+
rc.ClusterConfig = &storage.ClusterRunConfig{
593+
Addrs: redisConfig.ClusterConfig.Addrs,
594+
}
595+
}
596+
595597
return &storage.RunConfig{
596598
Type: string(storage.TypeRedis),
597599
RedisConfig: rc,
598600
}, nil
599601
}
600602

603+
// validateRedisConnectionMode checks that exactly one of addr, sentinelConfig,
604+
// or clusterConfig is set on the Redis storage config.
605+
func validateRedisConnectionMode(cfg *mcpv1beta1.RedisStorageConfig) error {
606+
modes := 0
607+
if cfg.Addr != "" {
608+
modes++
609+
}
610+
if cfg.SentinelConfig != nil {
611+
modes++
612+
}
613+
if cfg.ClusterConfig != nil {
614+
modes++
615+
}
616+
if modes > 1 {
617+
return fmt.Errorf("addr, sentinelConfig, and clusterConfig are mutually exclusive for Redis storage")
618+
}
619+
if modes == 0 {
620+
return fmt.Errorf("one of addr (standalone), sentinelConfig (Sentinel)," +
621+
" or clusterConfig (Cluster) is required for Redis storage")
622+
}
623+
return nil
624+
}
625+
601626
// convertRedisTLSConfig converts CRD RedisTLSConfig to RunConfig.
602627
// isSentinel determines which mount path to use for the CA cert file.
603628
func convertRedisTLSConfig(cfg *mcpv1beta1.RedisTLSConfig, isSentinel bool) *storage.RedisTLSRunConfig {

cmd/thv-operator/pkg/controllerutil/authserver_test.go

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1674,7 +1674,7 @@ func TestBuildStorageRunConfig(t *testing.T) {
16741674
errContains: "redis config is required",
16751675
},
16761676
{
1677-
name: "Redis storage missing both addr and sentinelConfig returns error",
1677+
name: "Redis storage missing addr, sentinelConfig, and clusterConfig returns error",
16781678
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
16791679
Issuer: "https://auth.example.com",
16801680
Storage: &mcpv1beta1.AuthServerStorageConfig{
@@ -1688,7 +1688,7 @@ func TestBuildStorageRunConfig(t *testing.T) {
16881688
},
16891689
},
16901690
wantErr: true,
1691-
errContains: "either addr (standalone) or sentinel config is required",
1691+
errContains: "one of addr (standalone), sentinelConfig (Sentinel), or clusterConfig (Cluster) is required",
16921692
},
16931693
{
16941694
name: "Redis storage with both addr and sentinelConfig returns error",
@@ -1710,7 +1710,85 @@ func TestBuildStorageRunConfig(t *testing.T) {
17101710
},
17111711
},
17121712
wantErr: true,
1713-
errContains: "addr and sentinel config are mutually exclusive",
1713+
errContains: "mutually exclusive",
1714+
},
1715+
{
1716+
name: "Redis storage with addr and clusterConfig returns error",
1717+
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
1718+
Issuer: "https://auth.example.com",
1719+
Storage: &mcpv1beta1.AuthServerStorageConfig{
1720+
Type: mcpv1beta1.AuthServerStorageTypeRedis,
1721+
Redis: &mcpv1beta1.RedisStorageConfig{
1722+
Addr: "redis.example.com:6379",
1723+
ClusterConfig: &mcpv1beta1.RedisClusterConfig{
1724+
Addrs: []string{"node1.example.com:6379"},
1725+
},
1726+
ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{
1727+
UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "u"},
1728+
PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "p"},
1729+
},
1730+
},
1731+
},
1732+
},
1733+
wantErr: true,
1734+
errContains: "mutually exclusive",
1735+
},
1736+
{
1737+
name: "Redis storage with sentinelConfig and clusterConfig returns error",
1738+
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
1739+
Issuer: "https://auth.example.com",
1740+
Storage: &mcpv1beta1.AuthServerStorageConfig{
1741+
Type: mcpv1beta1.AuthServerStorageTypeRedis,
1742+
Redis: &mcpv1beta1.RedisStorageConfig{
1743+
SentinelConfig: &mcpv1beta1.RedisSentinelConfig{
1744+
MasterName: "mymaster",
1745+
SentinelAddrs: []string{"10.0.0.1:26379"},
1746+
},
1747+
ClusterConfig: &mcpv1beta1.RedisClusterConfig{
1748+
Addrs: []string{"node1.example.com:6379"},
1749+
},
1750+
ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{
1751+
UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "u"},
1752+
PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "s", Key: "p"},
1753+
},
1754+
},
1755+
},
1756+
},
1757+
wantErr: true,
1758+
errContains: "mutually exclusive",
1759+
},
1760+
{
1761+
name: "Redis cluster config builds correctly",
1762+
authConfig: &mcpv1beta1.EmbeddedAuthServerConfig{
1763+
Issuer: "https://auth.example.com",
1764+
Storage: &mcpv1beta1.AuthServerStorageConfig{
1765+
Type: mcpv1beta1.AuthServerStorageTypeRedis,
1766+
Redis: &mcpv1beta1.RedisStorageConfig{
1767+
ClusterConfig: &mcpv1beta1.RedisClusterConfig{
1768+
Addrs: []string{"node1.example.com:6379", "node2.example.com:6379"},
1769+
},
1770+
ACLUserConfig: &mcpv1beta1.RedisACLUserConfig{
1771+
UsernameSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "username"},
1772+
PasswordSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-secret", Key: "password"},
1773+
},
1774+
},
1775+
},
1776+
},
1777+
checkFunc: func(t *testing.T, cfg *storage.RunConfig) {
1778+
t.Helper()
1779+
assert.Equal(t, string(storage.TypeRedis), cfg.Type)
1780+
require.NotNil(t, cfg.RedisConfig)
1781+
require.NotNil(t, cfg.RedisConfig.ClusterConfig)
1782+
assert.Equal(t, []string{"node1.example.com:6379", "node2.example.com:6379"},
1783+
cfg.RedisConfig.ClusterConfig.Addrs)
1784+
assert.Empty(t, cfg.RedisConfig.Addr)
1785+
assert.Nil(t, cfg.RedisConfig.SentinelConfig)
1786+
assert.Equal(t, storage.AuthTypeACLUser, cfg.RedisConfig.AuthType)
1787+
require.NotNil(t, cfg.RedisConfig.ACLUserConfig)
1788+
assert.Equal(t, authrunner.RedisUsernameEnvVar, cfg.RedisConfig.ACLUserConfig.UsernameEnvVar)
1789+
assert.Equal(t, authrunner.RedisPasswordEnvVar, cfg.RedisConfig.ACLUserConfig.PasswordEnvVar)
1790+
assert.Equal(t, "thv:auth:{default:test-server}:", cfg.RedisConfig.KeyPrefix)
1791+
},
17141792
},
17151793
{
17161794
name: "Redis storage with standalone addr builds correctly",

deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpexternalauthconfigs.yaml

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -318,8 +318,26 @@ spec:
318318
description: |-
319319
Addr is the Redis server address for standalone mode (e.g., "host:port").
320320
Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
321-
a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
321+
a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig and clusterConfig.
322322
type: string
323+
clusterConfig:
324+
description: |-
325+
ClusterConfig holds Redis Cluster configuration.
326+
Use for managed Redis services that use the Redis Cluster protocol (e.g., GCP Memorystore Cluster,
327+
AWS ElastiCache Serverless). Mutually exclusive with addr and sentinelConfig.
328+
properties:
329+
addrs:
330+
description: |-
331+
Addrs is the list of seed node host:port addresses for the Redis Cluster.
332+
At least one address is required; go-redis discovers other nodes automatically.
333+
items:
334+
type: string
335+
minItems: 1
336+
type: array
337+
x-kubernetes-list-type: atomic
338+
required:
339+
- addrs
340+
type: object
323341
dialTimeout:
324342
default: 5s
325343
description: |-
@@ -337,7 +355,7 @@ spec:
337355
sentinelConfig:
338356
description: |-
339357
SentinelConfig holds Redis Sentinel configuration.
340-
Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr.
358+
Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr and clusterConfig.
341359
properties:
342360
db:
343361
default: 0
@@ -442,9 +460,10 @@ spec:
442460
- aclUserConfig
443461
type: object
444462
x-kubernetes-validations:
445-
- message: exactly one of addr (standalone) or sentinelConfig
446-
(Sentinel) must be set
447-
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
463+
- message: exactly one of addr (standalone), sentinelConfig
464+
(Sentinel), or clusterConfig (Cluster) must be set
465+
rule: '(self.addr.size() > 0 ? 1 : 0) + (has(self.sentinelConfig)
466+
? 1 : 0) + (has(self.clusterConfig) ? 1 : 0) == 1'
448467
type:
449468
default: memory
450469
description: |-
@@ -1366,8 +1385,26 @@ spec:
13661385
description: |-
13671386
Addr is the Redis server address for standalone mode (e.g., "host:port").
13681387
Use for managed Redis services (GCP Memorystore, AWS ElastiCache) that present
1369-
a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig.
1388+
a single endpoint and manage HA internally. Mutually exclusive with sentinelConfig and clusterConfig.
13701389
type: string
1390+
clusterConfig:
1391+
description: |-
1392+
ClusterConfig holds Redis Cluster configuration.
1393+
Use for managed Redis services that use the Redis Cluster protocol (e.g., GCP Memorystore Cluster,
1394+
AWS ElastiCache Serverless). Mutually exclusive with addr and sentinelConfig.
1395+
properties:
1396+
addrs:
1397+
description: |-
1398+
Addrs is the list of seed node host:port addresses for the Redis Cluster.
1399+
At least one address is required; go-redis discovers other nodes automatically.
1400+
items:
1401+
type: string
1402+
minItems: 1
1403+
type: array
1404+
x-kubernetes-list-type: atomic
1405+
required:
1406+
- addrs
1407+
type: object
13711408
dialTimeout:
13721409
default: 5s
13731410
description: |-
@@ -1385,7 +1422,7 @@ spec:
13851422
sentinelConfig:
13861423
description: |-
13871424
SentinelConfig holds Redis Sentinel configuration.
1388-
Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr.
1425+
Use for self-managed Redis with Sentinel-based HA. Mutually exclusive with addr and clusterConfig.
13891426
properties:
13901427
db:
13911428
default: 0
@@ -1490,9 +1527,10 @@ spec:
14901527
- aclUserConfig
14911528
type: object
14921529
x-kubernetes-validations:
1493-
- message: exactly one of addr (standalone) or sentinelConfig
1494-
(Sentinel) must be set
1495-
rule: (self.addr.size() > 0) != has(self.sentinelConfig)
1530+
- message: exactly one of addr (standalone), sentinelConfig
1531+
(Sentinel), or clusterConfig (Cluster) must be set
1532+
rule: '(self.addr.size() > 0 ? 1 : 0) + (has(self.sentinelConfig)
1533+
? 1 : 0) + (has(self.clusterConfig) ? 1 : 0) == 1'
14961534
type:
14971535
default: memory
14981536
description: |-

0 commit comments

Comments
 (0)