Skip to content

Commit 9ad31e5

Browse files
jonburdoclaude
andcommitted
Add PVC source support for MCPRegistry
Enables MCPRegistry resources to use PersistentVolumeClaims as a data source in addition to existing ConfigMap, Git, and API sources. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Jon Burdo <[email protected]>
1 parent 2f1c0f5 commit 9ad31e5

File tree

13 files changed

+1252
-29
lines changed

13 files changed

+1252
-29
lines changed

cmd/thv-operator/REGISTRY.md

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,89 @@ spec:
200200
- HTTPS is recommended for production use
201201
- Authentication support planned for future release
202202

203+
### PVC Source
204+
205+
Store registry data in PersistentVolumeClaims for dynamic, persistent storage:
206+
207+
```yaml
208+
spec:
209+
registries:
210+
- name: production
211+
format: toolhive
212+
pvcRef:
213+
claimName: registry-data-pvc
214+
path: production/registry.json # Path within the PVC
215+
syncPolicy:
216+
interval: "1h"
217+
```
218+
219+
**How PVC mounting works:**
220+
- Each registry gets its own volume mount at `/config/registry/{registryName}/`
221+
- File path becomes: `/config/registry/{registryName}/{path}`
222+
- Multiple registries can share the same PVC by mounting it at different paths
223+
- Consistent with ConfigMap source behavior (all sources use `{registryName}` pattern)
224+
225+
**PVC Structure Examples:**
226+
227+
Single PVC with multiple registries:
228+
```
229+
PVC "shared-data":
230+
/prod-data/registry.json
231+
/dev-data/registry.json
232+
233+
Registry "production": pvcRef: {claimName: shared-data, path: prod-data/registry.json}
234+
→ Mounted at: /config/registry/production/
235+
→ File path: /config/registry/production/prod-data/registry.json
236+
237+
Registry "development": pvcRef: {claimName: shared-data, path: dev-data/registry.json}
238+
→ Mounted at: /config/registry/development/
239+
→ File path: /config/registry/development/dev-data/registry.json
240+
241+
Note: Same PVC mounted twice at different paths, allowing independent registry access
242+
```
243+
244+
**Populating PVC Data:**
245+
246+
The PVC can be populated using:
247+
- **Kubernetes Job** (recommended for initial setup)
248+
- **Init container** in a deployment
249+
- **Manual copy**: `kubectl cp registry.json pod:/path`
250+
- **CSI driver** that provides pre-populated data
251+
- **External sync process** that writes to the PVC
252+
253+
Example populate Job:
254+
```yaml
255+
apiVersion: batch/v1
256+
kind: Job
257+
metadata:
258+
name: populate-registry
259+
spec:
260+
template:
261+
spec:
262+
containers:
263+
- name: populate
264+
image: busybox
265+
command: ["/bin/sh", "-c"]
266+
args:
267+
- |
268+
mkdir -p /data/production
269+
cat > /data/production/registry.json <<EOF
270+
{"version": "1.0.0", "servers": {...}}
271+
EOF
272+
volumeMounts:
273+
- name: data
274+
mountPath: /data
275+
volumes:
276+
- name: data
277+
persistentVolumeClaim:
278+
claimName: registry-data-pvc
279+
restartPolicy: OnFailure
280+
```
281+
282+
**See Also:**
283+
- Complete PVC example: [examples/operator/mcp-registries/mcpregistry-pvc.yaml](../../examples/operator/mcp-registries/mcpregistry-pvc.yaml)
284+
- Multi-source example: [examples/operator/mcp-registries/mcpregistry-multi-source.yaml](../../examples/operator/mcp-registries/mcpregistry-multi-source.yaml)
285+
203286
### Registry Formats
204287

205288
**ToolHive Format** (default):
@@ -557,4 +640,4 @@ Each registry configuration must have a unique `name` within the MCPRegistry.
557640
- [MCPServer Documentation](README.md#usage)
558641
- [Operator Installation](../../docs/kind/deploying-toolhive-operator.md)
559642
- [Registry Examples](../../examples/operator/mcp-registries/)
560-
- [Registry Schema](../../pkg/registry/data/schema.json)
643+
- [Registry Schema](../../pkg/registry/data/schema.json)

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

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,6 @@ import (
88
"k8s.io/apimachinery/pkg/runtime"
99
)
1010

11-
const (
12-
// RegistrySourceTypeConfigMap is the type for registry data stored in ConfigMaps
13-
RegistrySourceTypeConfigMap = "configmap"
14-
15-
// RegistrySourceTypeGit is the type for registry data stored in Git repositories
16-
RegistrySourceTypeGit = "git"
17-
18-
// RegistrySourceTypeAPI is the type for registry data fetched from API endpoints
19-
RegistrySourceTypeAPI = "api"
20-
)
21-
2211
// Registry formats
2312
const (
2413
// RegistryFormatToolHive is the native ToolHive registry format
@@ -83,20 +72,25 @@ type MCPRegistryConfig struct {
8372
Format string `json:"format,omitempty"`
8473

8574
// ConfigMapRef defines the ConfigMap source configuration
86-
// Mutually exclusive with Git and API
75+
// Mutually exclusive with Git, API, and PVCRef
8776
// +optional
8877
ConfigMapRef *corev1.ConfigMapKeySelector `json:"configMapRef,omitempty"`
8978

9079
// Git defines the Git repository source configuration
91-
// Mutually exclusive with ConfigMapRef and API
80+
// Mutually exclusive with ConfigMapRef, API, and PVCRef
9281
// +optional
9382
Git *GitSource `json:"git,omitempty"`
9483

9584
// API defines the API source configuration
96-
// Mutually exclusive with ConfigMapRef and Git
85+
// Mutually exclusive with ConfigMapRef, Git, and PVCRef
9786
// +optional
9887
API *APISource `json:"api,omitempty"`
9988

89+
// PVCRef defines the PersistentVolumeClaim source configuration
90+
// Mutually exclusive with ConfigMapRef, Git, and API
91+
// +optional
92+
PVCRef *PVCSource `json:"pvcRef,omitempty"`
93+
10094
// SyncPolicy defines the automatic synchronization behavior for this registry.
10195
// If specified, enables automatic synchronization at the given interval.
10296
// Manual synchronization is always supported via annotation-based triggers
@@ -156,6 +150,43 @@ type APISource struct {
156150
Endpoint string `json:"endpoint"`
157151
}
158152

153+
// PVCSource defines PersistentVolumeClaim source configuration
154+
type PVCSource struct {
155+
// ClaimName is the name of the PersistentVolumeClaim
156+
// +kubebuilder:validation:Required
157+
// +kubebuilder:validation:MinLength=1
158+
ClaimName string `json:"claimName"`
159+
160+
// Path is the relative path to the registry file within the PVC.
161+
// The PVC is mounted at /config/registry/{registryName}/.
162+
// The full file path becomes: /config/registry/{registryName}/{path}
163+
//
164+
// This design:
165+
// - Each registry gets its own mount point (consistent with ConfigMap sources)
166+
// - Multiple registries can share the same PVC by mounting it at different paths
167+
// - Users control PVC organization freely via the path field
168+
//
169+
// Examples:
170+
// Registry "production" using PVC "shared-data" with path "prod/registry.json":
171+
// PVC contains /prod/registry.json → accessed at /config/registry/production/prod/registry.json
172+
//
173+
// Registry "development" using SAME PVC "shared-data" with path "dev/registry.json":
174+
// PVC contains /dev/registry.json → accessed at /config/registry/development/dev/registry.json
175+
// (Same PVC, different mount path)
176+
//
177+
// Registry "staging" using DIFFERENT PVC "other-pvc" with path "registry.json":
178+
// PVC contains /registry.json → accessed at /config/registry/staging/registry.json
179+
// (Different PVC, independent mount)
180+
//
181+
// Registry "team-a" with path "v1/servers.json":
182+
// PVC contains /v1/servers.json → accessed at /config/registry/team-a/v1/servers.json
183+
// (Subdirectories allowed in path)
184+
// +kubebuilder:validation:Pattern=^.*\.json$
185+
// +kubebuilder:default=registry.json
186+
// +optional
187+
Path string `json:"path,omitempty"`
188+
}
189+
159190
// SyncPolicy defines automatic synchronization behavior.
160191
// When specified, enables automatic synchronization at the given interval.
161192
// Manual synchronization via annotation-based triggers is always available

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

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

cmd/thv-operator/pkg/registryapi/config/config.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,16 @@ func validateRegistryNames(registries []mcpv1alpha1.MCPRegistryConfig) error {
285285
}
286286

287287
func buildFilePath(registryName string) *FileConfig {
288+
return buildFilePathWithCustomName(registryName, RegistryJSONFileName)
289+
}
290+
291+
func buildFilePathWithCustomName(registryName string, filename string) *FileConfig {
288292
return &FileConfig{
289-
Path: filepath.Join(RegistryJSONFilePath, registryName, RegistryJSONFileName),
293+
Path: filepath.Join(RegistryJSONFilePath, registryName, filename),
290294
}
291295
}
292296

297+
//nolint:gocyclo // Complexity is acceptable for handling multiple source types
293298
func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*RegistryConfig, error) {
294299
if registrySpec.Name == "" {
295300
return nil, fmt.Errorf("registry name is required")
@@ -330,12 +335,23 @@ func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*Registry
330335
}
331336
registryConfig.API = apiConfig
332337
}
338+
if registrySpec.PVCRef != nil {
339+
sourceCount++
340+
// PVC sources are mounted at /config/registry/{registryName}/
341+
// File path: /config/registry/{registryName}/{pvcRef.path}
342+
// Multiple registries can share the same PVC by mounting it at different paths
343+
pvcPath := RegistryJSONFileName
344+
if registrySpec.PVCRef.Path != "" {
345+
pvcPath = registrySpec.PVCRef.Path
346+
}
347+
registryConfig.File = buildFilePathWithCustomName(registrySpec.Name, pvcPath)
348+
}
333349

334350
if sourceCount == 0 {
335-
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, or API) must be specified")
351+
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, API, or PVCRef) must be specified")
336352
}
337353
if sourceCount > 1 {
338-
return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, or API) can be specified")
354+
return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, API, or PVCRef) can be specified")
339355
}
340356

341357
// Build sync policy

cmd/thv-operator/pkg/registryapi/config/config_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,127 @@ func TestBuildConfig_MultipleRegistries(t *testing.T) {
10011001
assert.Equal(t, []string{"server-*"}, config.Registries[1].Filter.Names.Include)
10021002
}
10031003

1004+
func TestBuildConfig_PVCSource(t *testing.T) {
1005+
t.Parallel()
1006+
1007+
t.Run("valid pvc source with default path", func(t *testing.T) {
1008+
t.Parallel()
1009+
mcpRegistry := &mcpv1alpha1.MCPRegistry{
1010+
ObjectMeta: metav1.ObjectMeta{
1011+
Name: "test-registry",
1012+
},
1013+
Spec: mcpv1alpha1.MCPRegistrySpec{
1014+
Registries: []mcpv1alpha1.MCPRegistryConfig{
1015+
{
1016+
Name: "pvc-registry",
1017+
Format: mcpv1alpha1.RegistryFormatToolHive,
1018+
PVCRef: &mcpv1alpha1.PVCSource{
1019+
ClaimName: "registry-data-pvc",
1020+
},
1021+
SyncPolicy: &mcpv1alpha1.SyncPolicy{
1022+
Interval: "1h",
1023+
},
1024+
},
1025+
},
1026+
},
1027+
}
1028+
1029+
manager := NewConfigManagerForTesting(mcpRegistry)
1030+
config, err := manager.BuildConfig()
1031+
1032+
require.NoError(t, err)
1033+
require.NotNil(t, config)
1034+
assert.Equal(t, "test-registry", config.RegistryName)
1035+
require.Len(t, config.Registries, 1)
1036+
assert.Equal(t, "pvc-registry", config.Registries[0].Name)
1037+
assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Registries[0].Format)
1038+
require.NotNil(t, config.Registries[0].File)
1039+
// Path: /config/registry/{registryName}/{pvcRef.path}
1040+
assert.Equal(t, filepath.Join(RegistryJSONFilePath, "pvc-registry", RegistryJSONFileName), config.Registries[0].File.Path)
1041+
})
1042+
1043+
t.Run("valid pvc source with subdirectory path", func(t *testing.T) {
1044+
t.Parallel()
1045+
mcpRegistry := &mcpv1alpha1.MCPRegistry{
1046+
ObjectMeta: metav1.ObjectMeta{
1047+
Name: "test-registry",
1048+
},
1049+
Spec: mcpv1alpha1.MCPRegistrySpec{
1050+
Registries: []mcpv1alpha1.MCPRegistryConfig{
1051+
{
1052+
Name: "production-registry",
1053+
Format: mcpv1alpha1.RegistryFormatToolHive,
1054+
PVCRef: &mcpv1alpha1.PVCSource{
1055+
ClaimName: "registry-data-pvc",
1056+
Path: "production/v1/servers.json",
1057+
},
1058+
SyncPolicy: &mcpv1alpha1.SyncPolicy{
1059+
Interval: "30m",
1060+
},
1061+
},
1062+
},
1063+
},
1064+
}
1065+
1066+
manager := NewConfigManagerForTesting(mcpRegistry)
1067+
config, err := manager.BuildConfig()
1068+
1069+
require.NoError(t, err)
1070+
require.NotNil(t, config)
1071+
require.Len(t, config.Registries, 1)
1072+
assert.Equal(t, "production-registry", config.Registries[0].Name)
1073+
require.NotNil(t, config.Registries[0].File)
1074+
// Path: /config/registry/{registryName}/{pvcRef.path}
1075+
assert.Equal(t, filepath.Join(RegistryJSONFilePath, "production-registry", "production/v1/servers.json"), config.Registries[0].File.Path)
1076+
})
1077+
1078+
t.Run("valid pvc source with filter", func(t *testing.T) {
1079+
t.Parallel()
1080+
mcpRegistry := &mcpv1alpha1.MCPRegistry{
1081+
ObjectMeta: metav1.ObjectMeta{
1082+
Name: "test-registry",
1083+
},
1084+
Spec: mcpv1alpha1.MCPRegistrySpec{
1085+
Registries: []mcpv1alpha1.MCPRegistryConfig{
1086+
{
1087+
Name: "filtered-pvc",
1088+
Format: mcpv1alpha1.RegistryFormatToolHive,
1089+
PVCRef: &mcpv1alpha1.PVCSource{
1090+
ClaimName: "registry-data-pvc",
1091+
Path: "registry.json",
1092+
},
1093+
SyncPolicy: &mcpv1alpha1.SyncPolicy{
1094+
Interval: "15m",
1095+
},
1096+
Filter: &mcpv1alpha1.RegistryFilter{
1097+
NameFilters: &mcpv1alpha1.NameFilter{
1098+
Include: []string{"prod-*"},
1099+
},
1100+
Tags: &mcpv1alpha1.TagFilter{
1101+
Include: []string{"production"},
1102+
},
1103+
},
1104+
},
1105+
},
1106+
},
1107+
}
1108+
1109+
manager := NewConfigManagerForTesting(mcpRegistry)
1110+
config, err := manager.BuildConfig()
1111+
1112+
require.NoError(t, err)
1113+
require.NotNil(t, config)
1114+
require.Len(t, config.Registries, 1)
1115+
assert.Equal(t, "filtered-pvc", config.Registries[0].Name)
1116+
require.NotNil(t, config.Registries[0].File)
1117+
// Verify filter is preserved
1118+
require.NotNil(t, config.Registries[0].Filter)
1119+
require.NotNil(t, config.Registries[0].Filter.Names)
1120+
assert.Equal(t, []string{"prod-*"}, config.Registries[0].Filter.Names.Include)
1121+
require.NotNil(t, config.Registries[0].Filter.Tags)
1122+
assert.Equal(t, []string{"production"}, config.Registries[0].Filter.Tags.Include)
1123+
})
1124+
}
10041125
func TestBuildConfig_DatabaseConfig(t *testing.T) {
10051126
t.Parallel()
10061127

0 commit comments

Comments
 (0)