Skip to content

Commit e2767b2

Browse files
committed
Inject BackendReplicas and Redis session config into MCPServer RunConfig
Populates ScalingConfig in the MCPServer RunConfig ConfigMap from spec.backendReplicas and spec.sessionStorage. Adds SessionRedisConfig (address, db, keyPrefix) to runner.ScalingConfig; the Redis password is intentionally excluded and injected as a pod env var instead. Both fields use nil-passthrough so unset specs leave the RunConfig fields absent (HPA/external controllers remain authoritative). Closes: #4218
1 parent aa18184 commit e2767b2

7 files changed

Lines changed: 367 additions & 0 deletions

File tree

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,41 @@ func (r *MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPSer
242242
return nil, fmt.Errorf("failed to populate middleware configs: %w", err)
243243
}
244244

245+
// Populate scaling config (BackendReplicas and Redis session storage).
246+
// Both fields use nil-passthrough: only set when explicitly configured in the spec.
247+
populateScalingConfig(runConfig, m)
248+
245249
return runConfig, nil
246250
}
247251

252+
// populateScalingConfig sets BackendReplicas and SessionRedis on the RunConfig from the MCPServer spec.
253+
// Fields are only set when present in the spec; nil means "not configured" and is left as-is.
254+
func populateScalingConfig(runConfig *runner.RunConfig, m *mcpv1alpha1.MCPServer) {
255+
hasBackendReplicas := m.Spec.BackendReplicas != nil
256+
hasRedis := m.Spec.SessionStorage != nil && m.Spec.SessionStorage.Provider == "redis"
257+
258+
if !hasBackendReplicas && !hasRedis {
259+
return
260+
}
261+
262+
if runConfig.ScalingConfig == nil {
263+
runConfig.ScalingConfig = &runner.ScalingConfig{}
264+
}
265+
266+
if hasBackendReplicas {
267+
val := *m.Spec.BackendReplicas
268+
runConfig.ScalingConfig.BackendReplicas = &val
269+
}
270+
271+
if hasRedis {
272+
runConfig.ScalingConfig.SessionRedis = &runner.SessionRedisConfig{
273+
Address: m.Spec.SessionStorage.Address,
274+
DB: m.Spec.SessionStorage.DB,
275+
KeyPrefix: m.Spec.SessionStorage.KeyPrefix,
276+
}
277+
}
278+
}
279+
248280
// labelsForRunConfig returns labels for run config ConfigMap
249281
func labelsForRunConfig(mcpServerName string) map[string]string {
250282
return map[string]string{

cmd/thv-operator/controllers/mcpserver_runconfig_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1703,3 +1703,154 @@ func TestEnsureRunConfigConfigMap_WithVaultInjection(t *testing.T) {
17031703
})
17041704
}
17051705
}
1706+
1707+
// TestPopulateScalingConfig tests BackendReplicas and SessionRedis injection into RunConfig.
1708+
func TestPopulateScalingConfig(t *testing.T) {
1709+
t.Parallel()
1710+
1711+
tests := []struct {
1712+
name string
1713+
spec mcpv1alpha1.MCPServerSpec
1714+
expected func(t *testing.T, sc *runner.ScalingConfig)
1715+
}{
1716+
{
1717+
name: "nil backendReplicas and nil sessionStorage — ScalingConfig stays nil",
1718+
spec: mcpv1alpha1.MCPServerSpec{
1719+
Image: testImage,
1720+
Transport: stdioTransport,
1721+
ProxyPort: 8080,
1722+
},
1723+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1724+
t.Helper()
1725+
assert.Nil(t, sc)
1726+
},
1727+
},
1728+
{
1729+
name: "backendReplicas set — written to ScalingConfig",
1730+
spec: mcpv1alpha1.MCPServerSpec{
1731+
Image: testImage,
1732+
Transport: stdioTransport,
1733+
ProxyPort: 8080,
1734+
BackendReplicas: int32Ptr(3),
1735+
},
1736+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1737+
t.Helper()
1738+
require.NotNil(t, sc)
1739+
require.NotNil(t, sc.BackendReplicas)
1740+
assert.Equal(t, int32(3), *sc.BackendReplicas)
1741+
},
1742+
},
1743+
{
1744+
name: "backendReplicas zero — written (not nil) to ScalingConfig",
1745+
spec: mcpv1alpha1.MCPServerSpec{
1746+
Image: testImage,
1747+
Transport: stdioTransport,
1748+
ProxyPort: 8080,
1749+
BackendReplicas: int32Ptr(0),
1750+
},
1751+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1752+
t.Helper()
1753+
require.NotNil(t, sc)
1754+
require.NotNil(t, sc.BackendReplicas)
1755+
assert.Equal(t, int32(0), *sc.BackendReplicas)
1756+
},
1757+
},
1758+
{
1759+
name: "sessionStorage nil — SessionRedis stays nil",
1760+
spec: mcpv1alpha1.MCPServerSpec{
1761+
Image: testImage,
1762+
Transport: stdioTransport,
1763+
ProxyPort: 8080,
1764+
BackendReplicas: int32Ptr(2),
1765+
},
1766+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1767+
t.Helper()
1768+
require.NotNil(t, sc)
1769+
assert.Nil(t, sc.SessionRedis)
1770+
},
1771+
},
1772+
{
1773+
name: "sessionStorage memory — SessionRedis stays nil",
1774+
spec: mcpv1alpha1.MCPServerSpec{
1775+
Image: testImage,
1776+
Transport: stdioTransport,
1777+
ProxyPort: 8080,
1778+
SessionStorage: &mcpv1alpha1.SessionStorageConfig{
1779+
Provider: "memory",
1780+
},
1781+
},
1782+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1783+
t.Helper()
1784+
assert.Nil(t, sc)
1785+
},
1786+
},
1787+
{
1788+
name: "sessionStorage redis — address/db/keyPrefix written to SessionRedis",
1789+
spec: mcpv1alpha1.MCPServerSpec{
1790+
Image: testImage,
1791+
Transport: stdioTransport,
1792+
ProxyPort: 8080,
1793+
SessionStorage: &mcpv1alpha1.SessionStorageConfig{
1794+
Provider: "redis",
1795+
Address: "redis.default.svc:6379",
1796+
DB: 2,
1797+
KeyPrefix: "thv:",
1798+
},
1799+
},
1800+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1801+
t.Helper()
1802+
require.NotNil(t, sc)
1803+
require.NotNil(t, sc.SessionRedis)
1804+
assert.Equal(t, "redis.default.svc:6379", sc.SessionRedis.Address)
1805+
assert.Equal(t, int32(2), sc.SessionRedis.DB)
1806+
assert.Equal(t, "thv:", sc.SessionRedis.KeyPrefix)
1807+
},
1808+
},
1809+
{
1810+
name: "sessionStorage redis with passwordRef — password NOT in SessionRedis",
1811+
spec: mcpv1alpha1.MCPServerSpec{
1812+
Image: testImage,
1813+
Transport: stdioTransport,
1814+
ProxyPort: 8080,
1815+
SessionStorage: &mcpv1alpha1.SessionStorageConfig{
1816+
Provider: "redis",
1817+
Address: "redis:6379",
1818+
PasswordRef: &mcpv1alpha1.SecretKeyRef{
1819+
Name: "redis-secret",
1820+
Key: "password",
1821+
},
1822+
},
1823+
},
1824+
expected: func(t *testing.T, sc *runner.ScalingConfig) {
1825+
t.Helper()
1826+
require.NotNil(t, sc)
1827+
require.NotNil(t, sc.SessionRedis)
1828+
assert.Equal(t, "redis:6379", sc.SessionRedis.Address)
1829+
// Password must NOT be stored in the RunConfig (it's a pod env var)
1830+
assert.Empty(t, sc.SessionRedis.KeyPrefix)
1831+
},
1832+
},
1833+
}
1834+
1835+
for _, tt := range tests {
1836+
t.Run(tt.name, func(t *testing.T) {
1837+
t.Parallel()
1838+
1839+
m := &mcpv1alpha1.MCPServer{
1840+
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "default"},
1841+
Spec: tt.spec,
1842+
}
1843+
1844+
r := &MCPServerReconciler{
1845+
Client: fake.NewClientBuilder().
1846+
WithScheme(createRunConfigTestScheme()).
1847+
WithObjects(m).
1848+
Build(),
1849+
}
1850+
1851+
runConfig, err := r.createRunConfigFromMCPServer(m)
1852+
require.NoError(t, err)
1853+
tt.expected(t, runConfig.ScalingConfig)
1854+
})
1855+
}
1856+
}

docs/server/docs.go

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

docs/server/swagger.json

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

docs/server/swagger.yaml

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

pkg/runner/config.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,26 @@ type ScalingConfig struct {
233233
// When nil, replicas are unmanaged (preserving HPA or manual kubectl control).
234234
// When set (including 0), the value is an explicit replica count.
235235
BackendReplicas *int32 `json:"backend_replicas,omitempty" yaml:"backend_replicas,omitempty"`
236+
237+
// SessionRedis holds non-sensitive Redis connection parameters for distributed session storage.
238+
// Populated only when MCPServer.spec.sessionStorage.provider == "redis".
239+
// The Redis password is not included — it is injected as env var THV_SESSION_REDIS_PASSWORD.
240+
// +optional
241+
SessionRedis *SessionRedisConfig `json:"session_redis,omitempty" yaml:"session_redis,omitempty"`
242+
}
243+
244+
// SessionRedisConfig contains non-sensitive Redis connection parameters used for distributed
245+
// session storage when the operator is configured with sessionStorage.provider == "redis".
246+
// The Redis password is excluded and injected separately as env var THV_SESSION_REDIS_PASSWORD.
247+
type SessionRedisConfig struct {
248+
// Address is the Redis server address (host:port).
249+
Address string `json:"address,omitempty" yaml:"address,omitempty"`
250+
251+
// DB is the Redis database number.
252+
DB int32 `json:"db,omitempty" yaml:"db,omitempty"`
253+
254+
// KeyPrefix is an optional prefix applied to all Redis keys used by ToolHive.
255+
KeyPrefix string `json:"key_prefix,omitempty" yaml:"key_prefix,omitempty"`
236256
}
237257

238258
// WriteJSON serializes the RunConfig to JSON and writes it to the provided writer

0 commit comments

Comments
 (0)