Skip to content

Commit 9c8f5ec

Browse files
yroblataskbot
andauthored
Add CRD type changes for horizontal scaling (#4363)
* Add CRD type changes for horizontal scaling Add SessionStorageConfig struct and scaling fields to MCPServer and VirtualMCPServer CRDs as the foundation for horizontal scaling support. - Add SessionStorageConfig struct with Provider, Address, DB, KeyPrefix, and PasswordRef fields; CEL rule enforces address is required for redis - Add Replicas *int32, BackendReplicas *int32, SessionStorage to MCPServerSpec - Add Replicas *int32, SessionStorage to VirtualMCPServerSpec - Nil pointer fields allow HPA to manage replicas without operator interference - Regenerate zz_generated.deepcopy.go and CRD manifests Closes: #4206 * rebase merge conflicts and fixes from review --------- Co-authored-by: taskbot <taskbot@users.noreply.github.com>
1 parent 64c2a58 commit 9c8f5ec

12 files changed

Lines changed: 807 additions & 0 deletions

cmd/thv-operator/api/v1alpha1/mcpserver_types.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,29 @@ type MCPServerSpec struct {
226226
// +kubebuilder:default=ClientIP
227227
// +optional
228228
SessionAffinity string `json:"sessionAffinity,omitempty"`
229+
230+
// Replicas is the desired number of proxy runner (thv run) pod replicas.
231+
// MCPServer creates two separate Deployments: one for the proxy runner and one
232+
// for the MCP server backend. This field controls the proxy runner Deployment.
233+
// When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
234+
// management to an HPA or other external controller.
235+
// +kubebuilder:validation:Minimum=0
236+
// +optional
237+
Replicas *int32 `json:"replicas,omitempty"`
238+
239+
// BackendReplicas is the desired number of MCP server backend pod replicas.
240+
// This controls the backend Deployment (the MCP server container itself),
241+
// independent of the proxy runner controlled by Replicas.
242+
// When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
243+
// management to an HPA or other external controller.
244+
// +kubebuilder:validation:Minimum=0
245+
// +optional
246+
BackendReplicas *int32 `json:"backendReplicas,omitempty"`
247+
248+
// SessionStorage configures session storage for stateful horizontal scaling.
249+
// When nil, no session storage is configured.
250+
// +optional
251+
SessionStorage *SessionStorageConfig `json:"sessionStorage,omitempty"`
229252
}
230253

231254
// ResourceOverrides defines overrides for annotations and labels on created resources
@@ -338,6 +361,44 @@ type SecretRef struct {
338361
TargetEnvName string `json:"targetEnvName,omitempty"`
339362
}
340363

364+
// SessionStorageConfig defines session storage configuration for horizontal scaling.
365+
//
366+
// This is the CRD/K8s-aware surface: it uses SecretKeyRef for secret resolution.
367+
// The reconciler resolves PasswordRef to a plain string and builds a
368+
// session.RedisConfig (pkg/transport/session) for the actual storage backend.
369+
//
370+
// TODO: Add a corresponding SessionStorageConfig to pkg/vmcp/config.Config so the
371+
// vMCP process receives session storage config through the existing config injection
372+
// path (same as Optimizer and Audit). The CRD type will remain separate because
373+
// PasswordRef is K8s-specific and gets resolved away before the config-pkg type.
374+
//
375+
// +kubebuilder:validation:XValidation:rule="self.provider == 'redis' ? has(self.address) : true",message="address is required"
376+
type SessionStorageConfig struct {
377+
// Provider is the session storage backend type
378+
// +kubebuilder:validation:Enum=memory;redis
379+
// +kubebuilder:validation:Required
380+
Provider string `json:"provider"`
381+
382+
// Address is the Redis server address (required when provider is redis)
383+
// +kubebuilder:validation:MinLength=1
384+
// +optional
385+
Address string `json:"address,omitempty"`
386+
387+
// DB is the Redis database number
388+
// +kubebuilder:validation:Minimum=0
389+
// +kubebuilder:default=0
390+
// +optional
391+
DB int32 `json:"db,omitempty"`
392+
393+
// KeyPrefix is an optional prefix for all Redis keys used by ToolHive
394+
// +optional
395+
KeyPrefix string `json:"keyPrefix,omitempty"`
396+
397+
// PasswordRef is a reference to a Secret key containing the Redis password
398+
// +optional
399+
PasswordRef *SecretKeyRef `json:"passwordRef,omitempty"`
400+
}
401+
341402
// Permission profile types
342403
const (
343404
// PermissionProfileTypeBuiltin is the type for built-in permission profiles
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package v1alpha1
5+
6+
import (
7+
"encoding/json"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestSessionStorageConfigJSONRoundtrip(t *testing.T) {
15+
t.Parallel()
16+
17+
tests := []struct {
18+
name string
19+
input SessionStorageConfig
20+
wantJSON string
21+
}{
22+
{
23+
name: "memory provider",
24+
input: SessionStorageConfig{
25+
Provider: "memory",
26+
},
27+
wantJSON: `{"provider":"memory"}`,
28+
},
29+
{
30+
name: "redis provider with address",
31+
input: SessionStorageConfig{
32+
Provider: "redis",
33+
Address: "redis:6379",
34+
},
35+
wantJSON: `{"provider":"redis","address":"redis:6379"}`,
36+
},
37+
{
38+
name: "redis provider with all fields",
39+
input: SessionStorageConfig{
40+
Provider: "redis",
41+
Address: "redis:6379",
42+
DB: 1,
43+
KeyPrefix: "thv:",
44+
},
45+
wantJSON: `{"provider":"redis","address":"redis:6379","db":1,"keyPrefix":"thv:"}`,
46+
},
47+
{
48+
name: "db zero is omitted",
49+
input: SessionStorageConfig{
50+
Provider: "redis",
51+
Address: "redis:6379",
52+
DB: 0,
53+
},
54+
wantJSON: `{"provider":"redis","address":"redis:6379"}`,
55+
},
56+
}
57+
58+
for _, tc := range tests {
59+
t.Run(tc.name, func(t *testing.T) {
60+
t.Parallel()
61+
b, err := json.Marshal(tc.input)
62+
require.NoError(t, err)
63+
assert.JSONEq(t, tc.wantJSON, string(b))
64+
})
65+
}
66+
}
67+
68+
func TestMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) {
69+
t.Parallel()
70+
71+
replicas := int32(3)
72+
backendReplicas := int32(2)
73+
74+
tests := []struct {
75+
name string
76+
spec MCPServerSpec
77+
wantKeys []string
78+
wantAbsent []string
79+
}{
80+
{
81+
name: "nil replicas are omitted",
82+
spec: MCPServerSpec{Image: "example/mcp:latest"},
83+
wantAbsent: []string{`"replicas"`, `"backendReplicas"`, `"sessionStorage"`},
84+
},
85+
{
86+
name: "set replicas are serialized",
87+
spec: MCPServerSpec{
88+
Image: "example/mcp:latest",
89+
Replicas: &replicas,
90+
BackendReplicas: &backendReplicas,
91+
},
92+
wantKeys: []string{`"replicas":3`, `"backendReplicas":2`},
93+
},
94+
{
95+
name: "sessionStorage is serialized when set",
96+
spec: MCPServerSpec{
97+
Image: "example/mcp:latest",
98+
SessionStorage: &SessionStorageConfig{
99+
Provider: "redis",
100+
Address: "redis:6379",
101+
},
102+
},
103+
wantKeys: []string{`"sessionStorage"`, `"provider":"redis"`},
104+
},
105+
}
106+
107+
for _, tc := range tests {
108+
t.Run(tc.name, func(t *testing.T) {
109+
t.Parallel()
110+
b, err := json.Marshal(tc.spec)
111+
require.NoError(t, err)
112+
out := string(b)
113+
for _, key := range tc.wantKeys {
114+
assert.Contains(t, out, key)
115+
}
116+
for _, key := range tc.wantAbsent {
117+
assert.NotContains(t, out, key)
118+
}
119+
})
120+
}
121+
}

cmd/thv-operator/api/v1alpha1/virtualmcpserver_types.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ type VirtualMCPServerSpec struct {
8282
// When nil, IncomingAuth uses an external IDP and behavior is unchanged.
8383
// +optional
8484
AuthServerConfig *EmbeddedAuthServerConfig `json:"authServerConfig,omitempty"`
85+
86+
// Replicas is the desired number of vMCP pod replicas.
87+
// VirtualMCPServer creates a single Deployment for the vMCP aggregator process,
88+
// so there is only one replicas field (unlike MCPServer which has separate
89+
// Replicas and BackendReplicas for its two Deployments).
90+
// When nil, the operator does not set Deployment.Spec.Replicas, leaving replica
91+
// management to an HPA or other external controller.
92+
// +kubebuilder:validation:Minimum=0
93+
// +optional
94+
Replicas *int32 `json:"replicas,omitempty"`
95+
96+
// SessionStorage configures session storage for stateful horizontal scaling.
97+
// When nil, no session storage is configured.
98+
// +optional
99+
SessionStorage *SessionStorageConfig `json:"sessionStorage,omitempty"`
85100
}
86101

87102
// EmbeddingServerRef references an existing EmbeddingServer resource by name.

cmd/thv-operator/api/v1alpha1/virtualmcpserver_types_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package v1alpha1
55

66
import (
7+
"encoding/json"
78
"testing"
89

910
"github.com/stretchr/testify/assert"
@@ -479,3 +480,58 @@ func TestValidateEmbeddingServer(t *testing.T) {
479480
})
480481
}
481482
}
483+
484+
func TestVirtualMCPServerSpecScalingFieldsJSONRoundtrip(t *testing.T) {
485+
t.Parallel()
486+
487+
replicas := int32(2)
488+
489+
tests := []struct {
490+
name string
491+
spec VirtualMCPServerSpec
492+
wantKeys []string
493+
wantAbsent []string
494+
}{
495+
{
496+
name: "nil replicas are omitted",
497+
spec: VirtualMCPServerSpec{
498+
IncomingAuth: &IncomingAuthConfig{Type: "anonymous"},
499+
},
500+
wantAbsent: []string{`"replicas"`, `"sessionStorage"`},
501+
},
502+
{
503+
name: "set replicas are serialized",
504+
spec: VirtualMCPServerSpec{
505+
IncomingAuth: &IncomingAuthConfig{Type: "anonymous"},
506+
Replicas: &replicas,
507+
},
508+
wantKeys: []string{`"replicas":2`},
509+
},
510+
{
511+
name: "sessionStorage is serialized when set",
512+
spec: VirtualMCPServerSpec{
513+
IncomingAuth: &IncomingAuthConfig{Type: "anonymous"},
514+
SessionStorage: &SessionStorageConfig{
515+
Provider: "redis",
516+
Address: "redis:6379",
517+
},
518+
},
519+
wantKeys: []string{`"sessionStorage"`, `"provider":"redis"`},
520+
},
521+
}
522+
523+
for _, tc := range tests {
524+
t.Run(tc.name, func(t *testing.T) {
525+
t.Parallel()
526+
b, err := json.Marshal(tc.spec)
527+
require.NoError(t, err)
528+
out := string(b)
529+
for _, key := range tc.wantKeys {
530+
assert.Contains(t, out, key)
531+
}
532+
for _, key := range tc.wantAbsent {
533+
assert.NotContains(t, out, key)
534+
}
535+
})
536+
}
537+
}

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

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

0 commit comments

Comments
 (0)