Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/thv-operator/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ func Run() {
// Bridge to slog for consistency with the rest of the ToolHive codebase.
ctrl.SetLogger(logr.FromSlogHandler(slog.Default().Handler()))

if err := ctrlutil.ValidateDefaultRedisConfig(); err != nil {
setupLog.Error(err, "invalid global Redis configuration")
os.Exit(1)
}

podNamespace, _ := os.LookupEnv("POD_NAMESPACE")

options := ctrl.Options{
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_runconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ func populateScalingConfigForRemoteProxy(runConfig *runner.RunConfig, proxy *mcp
}
runConfig.ScalingConfig.SessionRedis = &runner.SessionRedisConfig{
Address: def.Addr,
TLS: ctrlutil.SessionRedisTLSConfig(nil),
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/thv-operator/controllers/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -1437,7 +1437,7 @@ func (r *MCPServerReconciler) deploymentForMCPServer(
}

// Mount the session storage Redis CA bundle when TLS references one.
sessionTLSVolumes, sessionTLSMounts := ctrlutil.SessionRedisTLSVolumes(m.Spec.SessionStorage)
sessionTLSVolumes, sessionTLSMounts := ctrlutil.SessionRedisTLSVolumesWithDefault(m.Spec.SessionStorage)
volumes = append(volumes, sessionTLSVolumes...)
volumeMounts = append(volumeMounts, sessionTLSMounts...)

Expand Down Expand Up @@ -2094,7 +2094,7 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate(
return true
}

wantSessionTLSVolumes, _ := ctrlutil.SessionRedisTLSVolumes(mcpServer.Spec.SessionStorage)
wantSessionTLSVolumes, _ := ctrlutil.SessionRedisTLSVolumesWithDefault(mcpServer.Spec.SessionStorage)
if ctrlutil.SessionRedisTLSVolumeNeedsUpdate(deployment.Spec.Template.Spec.Volumes, wantSessionTLSVolumes) {
return true
}
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/controllers/mcpserver_runconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ func populateScalingConfig(runConfig *runner.RunConfig, m *mcpv1beta1.MCPServer)
} else if defaultRedis != nil {
runConfig.ScalingConfig.SessionRedis = &runner.SessionRedisConfig{
Address: defaultRedis.Addr,
TLS: ctrlutil.SessionRedisTLSConfig(nil),
}
}
}
Expand Down
124 changes: 118 additions & 6 deletions cmd/thv-operator/controllers/session_redis_tls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil"
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
"github.com/stacklok/toolhive/pkg/container/kubernetes"
"github.com/stacklok/toolhive/pkg/redisconfig"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/vmcp/workloads"
)

Expand All @@ -40,7 +42,43 @@ func sessionTLSStorage(caSecret string) *mcpv1beta1.SessionStorageConfig {
// referenced Secret is detected as drift even though the mounted path (and so
// the runtime config) is unchanged.
func TestSessionRedisTLSCAMountedAndDriftDetected(t *testing.T) {
t.Parallel()
t.Setenv("TOOLHIVE_DEFAULT_REDIS_SECRET_NAME", "")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_SECRET_KEY", "")
tests := []struct {
name string
global bool
memory bool
}{
{name: "explicit Redis TLS"},
{name: "global Redis TLS", global: true},
{name: "global Redis TLS with explicit memory", global: true, memory: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("TOOLHIVE_DEFAULT_REDIS_ADDR", "")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_ENABLED", "")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_NAME", "")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY", "")
testSessionRedisTLSCAMountedAndDriftDetected(t, tt.global, tt.memory)
})
}
}

func testSessionRedisTLSCAMountedAndDriftDetected(t *testing.T, global, memory bool) {
t.Helper()
storage := sessionTLSStorage
if global {
t.Setenv("TOOLHIVE_DEFAULT_REDIS_ADDR", "global-redis:6380")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_ENABLED", "true")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY", "ca.crt")
storage = func(caSecret string) *mcpv1beta1.SessionStorageConfig {
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_NAME", caSecret)
if memory {
return &mcpv1beta1.SessionStorageConfig{Provider: "memory"}
}
return nil
}
}

type harness struct {
// build returns the desired Deployment for a workload using caSecret.
Expand All @@ -52,16 +90,16 @@ func TestSessionRedisTLSCAMountedAndDriftDetected(t *testing.T) {
mcpServer := func(caSecret string) *mcpv1beta1.MCPServer {
return v1beta1test.NewMCPServer("tls-mcp", "default",
v1beta1test.WithTransport("streamable-http"),
v1beta1test.WithSessionStorage(sessionTLSStorage(caSecret)))
v1beta1test.WithSessionStorage(storage(caSecret)))
}
remoteProxy := func(caSecret string) *mcpv1beta1.MCPRemoteProxy {
return v1beta1test.NewMCPRemoteProxy("tls-proxy", "default",
v1beta1test.WithRemoteProxySessionStorage(sessionTLSStorage(caSecret)))
v1beta1test.WithRemoteProxySessionStorage(storage(caSecret)))
}
virtualServer := func(caSecret string) *mcpv1beta1.VirtualMCPServer {
return v1beta1test.NewVirtualMCPServer("tls-vmcp", "default",
v1beta1test.WithVMCPGroupRef("test-group"),
v1beta1test.WithVMCPSessionStorage(sessionTLSStorage(caSecret)))
v1beta1test.WithVMCPSessionStorage(storage(caSecret)))
}

kinds := map[string]harness{
Expand Down Expand Up @@ -115,8 +153,6 @@ func TestSessionRedisTLSCAMountedAndDriftDetected(t *testing.T) {

for name, h := range kinds {
t.Run(name, func(t *testing.T) {
t.Parallel()

dep := h.build(t, "redis-ca")
require.NotNil(t, dep)

Expand All @@ -126,6 +162,11 @@ func TestSessionRedisTLSCAMountedAndDriftDetected(t *testing.T) {
volume = &dep.Spec.Template.Spec.Volumes[i]
}
}
if memory && name == "MCPRemoteProxy" {
assert.Nil(t, volume, "explicit memory suppresses the remote proxy global fallback")
assert.False(t, h.needsUpdate(t, dep, "redis-ca"))
return
}
require.NotNil(t, volume, "the session storage CA Secret must be mounted")
require.NotNil(t, volume.Secret)
assert.Equal(t, "redis-ca", volume.Secret.SecretName)
Expand All @@ -143,6 +184,12 @@ func TestSessionRedisTLSCAMountedAndDriftDetected(t *testing.T) {
"a freshly built Deployment must not be flagged as drifted")
assert.True(t, h.needsUpdate(t, dep, "rotated-redis-ca"),
"switching the referenced CA Secret must trigger a Deployment update")
if global {
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY", "rotated.pem")
assert.True(t, h.needsUpdate(t, dep, "redis-ca"),
"switching the global CA Secret key must trigger a Deployment update")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY", "ca.crt")
}
})
}
}
Expand Down Expand Up @@ -184,3 +231,68 @@ func newRemoteProxyTLSReconciler(t *testing.T, proxy *mcpv1beta1.MCPRemoteProxy)
PlatformDetector: ctrlutil.NewSharedPlatformDetector(),
}
}

func TestGlobalRedisTLSRunConfigSelection(t *testing.T) {
t.Setenv("TOOLHIVE_DEFAULT_REDIS_ADDR", "global-redis:6380")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_ENABLED", "true")
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY", "ca.crt")

caTLS := &redisconfig.TLSConfig{CACertFile: "/etc/toolhive/session-redis-tls/ca.crt"}
tests := []struct {
name string
storage *mcpv1beta1.SessionStorageConfig
globalCA string
wantAddr string
wantTLS *redisconfig.TLSConfig
}{
{name: "global system roots", wantAddr: "global-redis:6380", wantTLS: &redisconfig.TLSConfig{}},
{name: "global private CA", globalCA: "global-ca", wantAddr: "global-redis:6380", wantTLS: caTLS},
{name: "explicit plaintext overrides global TLS", globalCA: "global-ca",
storage: &mcpv1beta1.SessionStorageConfig{Provider: "redis", Address: "local-redis:6379"}, wantAddr: "local-redis:6379"},
{name: "explicit system roots override global CA", globalCA: "global-ca",
storage: &mcpv1beta1.SessionStorageConfig{Provider: "redis", Address: "local-redis:6380", TLS: &mcpv1beta1.RedisTLSConfig{}},
wantAddr: "local-redis:6380", wantTLS: &redisconfig.TLSConfig{}},
{name: "explicit private CA overrides global CA", globalCA: "global-ca", storage: sessionTLSStorage("local-ca"),
wantAddr: "redis:6380", wantTLS: caTLS},
{name: "memory retains each resource existing precedence", globalCA: "global-ca",
storage: &mcpv1beta1.SessionStorageConfig{Provider: "memory"}, wantAddr: "global-redis:6380", wantTLS: caTLS},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_NAME", tt.globalCA)
key := ""
if tt.globalCA != "" {
key = "ca.crt"
}
t.Setenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY", key)
before := tt.storage.DeepCopy()
for _, remote := range []bool{false, true} {
name := "MCPServer"
if remote {
name = "MCPRemoteProxy"
}
t.Run(name, func(t *testing.T) {
cfg := &runner.RunConfig{}
if remote {
proxy := v1beta1test.NewMCPRemoteProxy("proxy", "default", v1beta1test.WithRemoteProxySessionStorage(tt.storage))
populateScalingConfigForRemoteProxy(cfg, proxy)
} else {
server := v1beta1test.NewMCPServer("server", "default", v1beta1test.WithSessionStorage(tt.storage))
populateScalingConfig(cfg, server)
}
if remote && tt.storage != nil && tt.storage.Provider == "memory" {
if cfg.ScalingConfig != nil {
assert.Nil(t, cfg.ScalingConfig.SessionRedis)
}
return
}
require.NotNil(t, cfg.ScalingConfig)
require.NotNil(t, cfg.ScalingConfig.SessionRedis)
assert.Equal(t, tt.wantAddr, cfg.ScalingConfig.SessionRedis.Address)
assert.Equal(t, tt.wantTLS, cfg.ScalingConfig.SessionRedis.TLS)
})
}
assert.Equal(t, before, tt.storage, "global fallback must not mutate spec.sessionStorage")
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ func (r *VirtualMCPServerReconciler) buildPodVolumesForVmcp(
volumeMounts = append(volumeMounts, authServerMounts...)
}

sessionTLSVolumes, sessionTLSMounts := ctrlutil.SessionRedisTLSVolumes(vmcp.Spec.SessionStorage)
sessionTLSVolumes, sessionTLSMounts := ctrlutil.SessionRedisTLSVolumesWithDefault(vmcp.Spec.SessionStorage)
volumes = append(volumes, sessionTLSVolumes...)
volumeMounts = append(volumeMounts, sessionTLSMounts...)

Expand Down
60 changes: 56 additions & 4 deletions cmd/thv-operator/pkg/controllerutil/defaultredis.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@

package controllerutil

import "os"
import (
"fmt"
"os"
"strconv"

mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
)

// DefaultRedisConfig holds global Redis defaults read from the operator's own
// environment variables. These are injected by the Helm chart when
Expand All @@ -17,14 +23,54 @@ type DefaultRedisConfig struct {
// SecretKey is the key within SecretName that holds the password.
// Defaults to "redis-password" when SecretName is set and no key is provided.
SecretKey string
// TLS configures verified transport security for the global Redis fallback.
// A referenced CA Secret must exist in each workload's namespace.
TLS *mcpv1beta1.RedisTLSConfig
}

// ReadDefaultRedisConfig returns the global Redis defaults from the operator's
// environment, or nil when TOOLHIVE_DEFAULT_REDIS_ADDR is not set.
// environment, or nil when TOOLHIVE_DEFAULT_REDIS_ADDR is not set. Invalid TLS
// settings panic rather than silently falling back to plaintext; the operator
// calls ValidateDefaultRedisConfig at startup to report these errors gracefully.
func ReadDefaultRedisConfig() *DefaultRedisConfig {
cfg, err := readDefaultRedisConfig()
if err != nil {
panic(err)
}
return cfg
}

// ValidateDefaultRedisConfig validates the global Redis environment before the
// operator starts its controllers. It validates references, not Secret contents;
// CA Secrets are projected separately into each workload's namespace.
func ValidateDefaultRedisConfig() error {
_, err := readDefaultRedisConfig()
return err
}

func readDefaultRedisConfig() (*DefaultRedisConfig, error) {
enabled := false
if raw := os.Getenv("TOOLHIVE_DEFAULT_REDIS_TLS_ENABLED"); raw != "" {
parsed, err := strconv.ParseBool(raw)
if err != nil {
return nil, fmt.Errorf("TOOLHIVE_DEFAULT_REDIS_TLS_ENABLED must be a boolean: %w", err)
}
enabled = parsed
}
caName := os.Getenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_NAME")
caKey := os.Getenv("TOOLHIVE_DEFAULT_REDIS_TLS_CA_SECRET_KEY")
if (caName == "") != (caKey == "") {
return nil, fmt.Errorf("default Redis TLS CA Secret name and key must both be set")
}
if caName != "" && !enabled {
return nil, fmt.Errorf("default Redis TLS CA Secret requires TOOLHIVE_DEFAULT_REDIS_TLS_ENABLED=true")
}
addr := os.Getenv("TOOLHIVE_DEFAULT_REDIS_ADDR")
if addr == "" {
return nil
if enabled {
return nil, fmt.Errorf("default Redis TLS requires TOOLHIVE_DEFAULT_REDIS_ADDR")
}
return nil, nil
}
cfg := &DefaultRedisConfig{
Addr: addr,
Expand All @@ -34,5 +80,11 @@ func ReadDefaultRedisConfig() *DefaultRedisConfig {
if cfg.SecretName != "" && cfg.SecretKey == "" {
cfg.SecretKey = "redis-password"
}
return cfg
if enabled {
cfg.TLS = &mcpv1beta1.RedisTLSConfig{}
if caName != "" {
cfg.TLS.CACertSecretRef = &mcpv1beta1.SecretKeyRef{Name: caName, Key: caKey}
}
}
return cfg, nil
}
Loading
Loading