Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
9 changes: 7 additions & 2 deletions cmd/thv-operator/api/v1beta1/mcpexternalauthconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2127,13 +2127,18 @@ type SentinelServiceRef struct {
// RedisTLSConfig configures TLS for Redis connections.
// Presence of this struct on a connection type enables TLS for that connection.
type RedisTLSConfig struct {
// InsecureSkipVerify skips TLS certificate verification.
// Use when connecting to services with self-signed certificates.
// InsecureSkipVerify disables server certificate and hostname verification.
// The connection is encrypted but not authenticated, so anyone able to
// impersonate the server can read the credentials and data sent over it.
// Intended for testing only; for self-signed or private CAs set
// caCertSecretRef instead.
// +optional
InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"`

// CACertSecretRef references a Secret containing a PEM-encoded CA certificate
// for verifying the server. When not specified, system root CAs are used.
// If the Secret or key does not exist, the pod cannot start (it stays in
// ContainerCreating) rather than connecting without verification.
// +optional
CACertSecretRef *SecretKeyRef `json:"caCertSecretRef,omitempty"`
}
Expand Down
15 changes: 15 additions & 0 deletions cmd/thv-operator/api/v1beta1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,11 @@ type SecretRef struct {
// into the vMCP ConfigMap so the vMCP process receives connection parameters at startup.
//
// +kubebuilder:validation:XValidation:rule="self.provider == 'redis' ? has(self.address) : true",message="address is required"
// +kubebuilder:validation:XValidation:rule="!has(self.tls) || self.provider == 'redis'",message="tls is only supported when provider is redis"
// +kubebuilder:validation:XValidation:rule="!has(self.tls) || !has(self.tls.caCertSecretRef) || !has(self.tls.insecureSkipVerify) || !self.tls.insecureSkipVerify",message="tls.insecureSkipVerify disables certificate verification, so tls.caCertSecretRef would be ignored; set only one"
// +kubebuilder:validation:XValidation:rule="!has(self.tls) || !has(self.tls.caCertSecretRef) || (size(self.tls.caCertSecretRef.name) > 0 && size(self.tls.caCertSecretRef.key) > 0)",message="tls.caCertSecretRef requires a non-empty name and key"
//
//nolint:lll // CEL validation rules exceed line length limit
type SessionStorageConfig struct {
// Provider is the session storage backend type
// +kubebuilder:validation:Enum=memory;redis
Expand All @@ -607,6 +612,16 @@ type SessionStorageConfig struct {
// PasswordRef is a reference to a Secret key containing the Redis password
// +optional
PasswordRef *SecretKeyRef `json:"passwordRef,omitempty"`

// TLS enables TLS for the Redis session storage connection and, when rate
// limiting is configured, for the rate-limit connection to the same Redis.
// When omitted, the connections are plaintext and the Redis password and
// session data cross the network unencrypted. An empty object enables TLS
// with server certificate verification against the system root CAs; set
// caCertSecretRef for a private CA. A failed handshake never falls back to
// plaintext. Only used when provider is redis.
// +optional
TLS *RedisTLSConfig `json:"tls,omitempty"`
}

// RateLimitConfig defines rate limiting configuration for an MCP server.
Expand Down
1 change: 1 addition & 0 deletions cmd/thv-operator/api/v1beta1/virtualmcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
// +kubebuilder:validation:XValidation:rule="!has(self.config) || !has(self.config.rateLimiting) || (has(self.sessionStorage) && self.sessionStorage.provider == 'redis')",message="config.rateLimiting requires sessionStorage with provider 'redis'"
// +kubebuilder:validation:XValidation:rule="!(has(self.config) && has(self.config.rateLimiting) && has(self.config.rateLimiting.perUser)) || (has(self.incomingAuth) && self.incomingAuth.type == 'oidc')",message="config.rateLimiting.perUser requires incomingAuth.type oidc"
// +kubebuilder:validation:XValidation:rule="!has(self.config) || !has(self.config.rateLimiting) || !has(self.config.rateLimiting.tools) || self.config.rateLimiting.tools.all(t, !has(t.perUser)) || (has(self.incomingAuth) && self.incomingAuth.type == 'oidc')",message="per-tool perUser rate limiting requires incomingAuth.type oidc"
// +kubebuilder:validation:XValidation:rule="!has(self.config) || !has(self.config.sessionStorage) || !has(self.config.sessionStorage.tls)",message="config.sessionStorage.tls is ignored by the operator; set sessionStorage.tls instead"
// +kubebuilder:validation:XValidation:rule="!(has(self.embeddingServerRef) && has(self.config) && has(self.config.optimizer) && has(self.config.optimizer.embeddingProvider) && self.config.optimizer.embeddingProvider == 'openai')",message="embeddingServerRef provisions a managed TEI server and cannot be combined with optimizer.embeddingProvider 'openai'; openai mode uses embeddingService directly"
//
//nolint:lll // CEL validation rules exceed line length limit
Expand Down
5 changes: 5 additions & 0 deletions cmd/thv-operator/api/v1beta1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion cmd/thv-operator/controllers/mcpremoteproxy_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -2016,7 +2016,9 @@ func (*MCPRemoteProxyReconciler) podTemplateSpecNeedsUpdate(
// Currently compares ImagePullSecrets — the merge of cluster-wide chart
// defaults with spec.resourceOverrides.proxyDeployment.imagePullSecrets. Uses
// equality.Semantic.DeepEqual so nil and empty slices are treated as equal,
// which matches Kubernetes' own serialization semantics.
// which matches Kubernetes' own serialization semantics. Also compares the
// session storage Redis CA volume, whose Secret is not covered by the RunConfig
// checksum.
func (r *MCPRemoteProxyReconciler) podSpecNeedsUpdate(
ctx context.Context,
deployment *appsv1.Deployment,
Expand Down Expand Up @@ -2047,6 +2049,11 @@ func (r *MCPRemoteProxyReconciler) podSpecNeedsUpdate(
!equality.Semantic.DeepEqual(
deployment.Spec.Template.Spec.Affinity,
expectedDeployment.Spec.Template.Spec.Affinity,
) ||
// podTemplateSpec may also patch the session storage CA volume.
ctrlutil.SessionRedisTLSVolumeNeedsUpdate(
deployment.Spec.Template.Spec.Volumes,
expectedDeployment.Spec.Template.Spec.Volumes,
)
}

Expand All @@ -2058,6 +2065,11 @@ func (r *MCPRemoteProxyReconciler) podSpecNeedsUpdate(
return true
}

wantSessionTLSVolumes, _ := ctrlutil.SessionRedisTLSVolumes(proxy.Spec.SessionStorage)
if ctrlutil.SessionRedisTLSVolumeNeedsUpdate(deployment.Spec.Template.Spec.Volumes, wantSessionTLSVolumes) {
return true
}

expected := r.imagePullSecretsForRemoteProxy(proxy)
return !equality.Semantic.DeepEqual(deployment.Spec.Template.Spec.ImagePullSecrets, expected)
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ func (r *MCPRemoteProxyReconciler) deploymentForMCPRemoteProxy(
}
env := r.buildEnvVarsForProxy(ctx, proxy)

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

// Add embedded auth server volumes and env vars. AuthServerRef takes precedence;
// externalAuthConfigRef is used as a fallback (legacy path).
configName := ctrlutil.EmbeddedAuthServerConfigName(proxy.Spec.ExternalAuthConfigRef, proxy.Spec.AuthServerRef)
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 @@ -207,6 +207,7 @@ func populateScalingConfigForRemoteProxy(runConfig *runner.RunConfig, proxy *mcp
Address: proxy.Spec.SessionStorage.Address,
DB: proxy.Spec.SessionStorage.DB,
KeyPrefix: proxy.Spec.SessionStorage.KeyPrefix,
TLS: ctrlutil.SessionRedisTLSConfig(proxy.Spec.SessionStorage),
}
}
// spec.sessionStorage was set explicitly — never fall through to the
Expand Down
16 changes: 16 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_runconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil"
"github.com/stacklok/toolhive/pkg/authz"
"github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
"github.com/stacklok/toolhive/pkg/redisconfig"
"github.com/stacklok/toolhive/pkg/runner"
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
)
Expand Down Expand Up @@ -780,6 +781,21 @@ func TestPopulateScalingConfigForRemoteProxy(t *testing.T) {
assert.Equal(t, "redis.default.svc:6379", sc.SessionRedis.Address)
assert.Equal(t, int32(2), sc.SessionRedis.DB)
assert.Equal(t, "thv:", sc.SessionRedis.KeyPrefix)
assert.Nil(t, sc.SessionRedis.TLS, "omitted tls must keep the existing plaintext behavior")
},
},
{
name: "redis with tls — TLS written to SessionRedis",
storage: &mcpv1beta1.SessionStorageConfig{
Provider: mcpv1beta1.SessionStorageProviderRedis,
Address: "redis:6380",
TLS: &mcpv1beta1.RedisTLSConfig{InsecureSkipVerify: true},
},
expected: func(t *testing.T, sc *runner.ScalingConfig) {
t.Helper()
require.NotNil(t, sc)
require.NotNil(t, sc.SessionRedis)
assert.Equal(t, &redisconfig.TLSConfig{InsecureSkipVerify: true}, sc.SessionRedis.TLS)
},
},
{
Expand Down
10 changes: 10 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,11 @@ func (r *MCPServerReconciler) deploymentForMCPServer(
}
}

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

// Add embedded auth server volumes and env vars. AuthServerRef takes precedence;
// externalAuthConfigRef is used as a fallback (legacy path).
if configName := ctrlutil.EmbeddedAuthServerConfigName(m.Spec.ExternalAuthConfigRef, m.Spec.AuthServerRef); configName != "" {
Expand Down Expand Up @@ -2081,6 +2086,11 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate(
return true
}

wantSessionTLSVolumes, _ := ctrlutil.SessionRedisTLSVolumes(mcpServer.Spec.SessionStorage)
if ctrlutil.SessionRedisTLSVolumeNeedsUpdate(deployment.Spec.Template.Spec.Volumes, wantSessionTLSVolumes) {
return true
}

// Check if the pod template spec has changed (including secrets)
// If service account is not specified, use the default MCP server service account
serviceAccount := mcpServer.Spec.ServiceAccount
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 @@ -339,6 +339,7 @@ func populateScalingConfig(runConfig *runner.RunConfig, m *mcpv1beta1.MCPServer)
Address: m.Spec.SessionStorage.Address,
DB: m.Spec.SessionStorage.DB,
KeyPrefix: m.Spec.SessionStorage.KeyPrefix,
TLS: ctrlutil.SessionRedisTLSConfig(m.Spec.SessionStorage),
}
} else if defaultRedis != nil {
runConfig.ScalingConfig.SessionRedis = &runner.SessionRedisConfig{
Expand Down
25 changes: 25 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_runconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/stacklok/toolhive/pkg/authz"
"github.com/stacklok/toolhive/pkg/authz/authorizers/cedar"
"github.com/stacklok/toolhive/pkg/container/kubernetes"
"github.com/stacklok/toolhive/pkg/redisconfig"
"github.com/stacklok/toolhive/pkg/runner"
transporttypes "github.com/stacklok/toolhive/pkg/transport/types"
)
Expand Down Expand Up @@ -1538,6 +1539,30 @@ func TestPopulateScalingConfig(t *testing.T) {
assert.Equal(t, "redis.default.svc:6379", sc.SessionRedis.Address)
assert.Equal(t, int32(2), sc.SessionRedis.DB)
assert.Equal(t, "thv:", sc.SessionRedis.KeyPrefix)
assert.Nil(t, sc.SessionRedis.TLS, "omitted tls must keep the existing plaintext behavior")
},
},
{
name: "sessionStorage redis with tls — TLS and mounted CA path written to SessionRedis",
spec: mcpv1beta1.MCPServerSpec{
Image: testImage,
Transport: stdioTransport,
ProxyPort: 8080,
SessionStorage: &mcpv1beta1.SessionStorageConfig{
Provider: "redis",
Address: "redis:6380",
TLS: &mcpv1beta1.RedisTLSConfig{
CACertSecretRef: &mcpv1beta1.SecretKeyRef{Name: "redis-ca", Key: "ca.crt"},
},
},
},
expected: func(t *testing.T, sc *runner.ScalingConfig) {
t.Helper()
require.NotNil(t, sc)
require.NotNil(t, sc.SessionRedis)
assert.Equal(t, &redisconfig.TLSConfig{
CACertFile: "/etc/toolhive/session-redis-tls/ca.crt",
}, sc.SessionRedis.TLS)
},
},
{
Expand Down
Loading
Loading