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
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