Skip to content

Commit 632f5b0

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 5093c91 commit 632f5b0

File tree

11 files changed

+1098
-14
lines changed

11 files changed

+1098
-14
lines changed

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const (
1616

1717
// RegistrySourceTypeAPI is the type for registry data fetched from API endpoints
1818
RegistrySourceTypeAPI = "api"
19+
20+
// RegistrySourceTypePVC is the type for registry data stored in PersistentVolumeClaims
21+
RegistrySourceTypePVC = "pvc"
1922
)
2023

2124
// Registry formats
@@ -58,20 +61,25 @@ type MCPRegistryConfig struct {
5861
Format string `json:"format,omitempty"`
5962

6063
// ConfigMapRef defines the ConfigMap source configuration
61-
// Mutually exclusive with Git and API
64+
// Mutually exclusive with Git, API, and PVCRef
6265
// +optional
6366
ConfigMapRef *corev1.ConfigMapKeySelector `json:"configMapRef,omitempty"`
6467

6568
// Git defines the Git repository source configuration
66-
// Mutually exclusive with ConfigMapRef and API
69+
// Mutually exclusive with ConfigMapRef, API, and PVCRef
6770
// +optional
6871
Git *GitSource `json:"git,omitempty"`
6972

7073
// API defines the API source configuration
71-
// Mutually exclusive with ConfigMapRef and Git
74+
// Mutually exclusive with ConfigMapRef, Git, and PVCRef
7275
// +optional
7376
API *APISource `json:"api,omitempty"`
7477

78+
// PVCRef defines the PersistentVolumeClaim source configuration
79+
// Mutually exclusive with ConfigMapRef, Git, and API
80+
// +optional
81+
PVCRef *PVCSource `json:"pvcRef,omitempty"`
82+
7583
// SyncPolicy defines the automatic synchronization behavior for this registry.
7684
// If specified, enables automatic synchronization at the given interval.
7785
// Manual synchronization is always supported via annotation-based triggers
@@ -131,6 +139,27 @@ type APISource struct {
131139
Endpoint string `json:"endpoint"`
132140
}
133141

142+
// PVCSource defines PersistentVolumeClaim source configuration
143+
type PVCSource struct {
144+
// ClaimName is the name of the PersistentVolumeClaim
145+
// +kubebuilder:validation:Required
146+
// +kubebuilder:validation:MinLength=1
147+
ClaimName string `json:"claimName"`
148+
149+
// Path is the relative path to the registry file within the PVC.
150+
// This can be a simple filename (e.g., "registry.json") or a path with
151+
// subdirectories (e.g., "production/data/registry.json").
152+
// The PVC will be mounted at /config/registry and this path will be appended.
153+
// Examples:
154+
// - "registry.json" -> /config/registry/registry.json
155+
// - "production/registry.json" -> /config/registry/production/registry.json
156+
// - "team-a/v1/servers.json" -> /config/registry/team-a/v1/servers.json
157+
// +kubebuilder:validation:Pattern=^[^/].*\.json$
158+
// +kubebuilder:default=registry.json
159+
// +optional
160+
Path string `json:"path,omitempty"`
161+
}
162+
134163
// SyncPolicy defines automatic synchronization behavior.
135164
// When specified, enables automatic synchronization at the given interval.
136165
// 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: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ func buildFilePath(registryName string) *FileConfig {
252252
}
253253
}
254254

255+
//nolint:gocyclo // Complexity is acceptable for handling multiple source types
255256
func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*RegistryConfig, error) {
256257
if registrySpec.Name == "" {
257258
return nil, fmt.Errorf("registry name is required")
@@ -292,12 +293,22 @@ func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*Registry
292293
}
293294
registryConfig.API = apiConfig
294295
}
296+
if registrySpec.PVCRef != nil {
297+
sourceCount++
298+
pvcPath := RegistryJSONFileName
299+
if registrySpec.PVCRef.Path != "" {
300+
pvcPath = registrySpec.PVCRef.Path
301+
}
302+
registryConfig.File = &FileConfig{
303+
Path: filepath.Join(RegistryJSONFilePath, pvcPath),
304+
}
305+
}
295306

296307
if sourceCount == 0 {
297-
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, or API) must be specified")
308+
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, API, or PVCRef) must be specified")
298309
}
299310
if sourceCount > 1 {
300-
return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, or API) can be specified")
311+
return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, API, or PVCRef) can be specified")
301312
}
302313

303314
// Build sync policy

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

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,3 +1000,125 @@ func TestBuildConfig_MultipleRegistries(t *testing.T) {
10001000
require.NotNil(t, config.Registries[1].Filter.Names)
10011001
assert.Equal(t, []string{"server-*"}, config.Registries[1].Filter.Names.Include)
10021002
}
1003+
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 defaults to registry.json at PVC root
1040+
assert.Equal(t, filepath.Join(RegistryJSONFilePath, 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 is used directly as relative path within PVC, demonstrating subdirectory support
1075+
assert.Equal(t, filepath.Join(RegistryJSONFilePath, "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+
}

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

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,17 @@ func WithRegistryStorageMount(containerName string) PodTemplateSpecOption {
155155
}
156156
}
157157

158-
// WithRegistrySourceMounts creates volumes and mounts for all registry source ConfigMaps.
159-
// This iterates through the registry sources and creates a volume and mount for each ConfigMapRef.
158+
// WithRegistrySourceMounts creates volumes and mounts for all registry sources (ConfigMap and PVC).
159+
// ConfigMaps: Each gets its own volume mounted at /config/registry/{registryName}/
160+
// PVCs: De-duplicated - each unique PVC mounted once at /config/registry/
160161
func WithRegistrySourceMounts(containerName string, registries []mcpv1alpha1.MCPRegistryConfig) PodTemplateSpecOption {
161162
return func(pts *corev1.PodTemplateSpec) {
163+
// Track PVCs we've already mounted to avoid duplicates
164+
mountedPVCs := make(map[string]bool)
165+
162166
for _, registry := range registries {
163167
if registry.ConfigMapRef != nil {
164-
// Create unique volume name for each ConfigMap source
168+
// ConfigMap: Create unique volume per source
165169
volumeName := fmt.Sprintf("registry-data-source-%s", registry.Name)
166170

167171
// Add the ConfigMap volume
@@ -183,15 +187,45 @@ func WithRegistrySourceMounts(containerName string, registries []mcpv1alpha1.MCP
183187
},
184188
})(pts)
185189

186-
// Add the volume mount
187-
// Mount path follows the pattern /config/registry/{registryName}/
190+
// Add the volume mount at registry-specific subdirectory
188191
mountPath := filepath.Join(config.RegistryJSONFilePath, registry.Name)
189192
WithVolumeMount(containerName, corev1.VolumeMount{
190193
Name: volumeName,
191194
MountPath: mountPath,
192195
ReadOnly: true,
193196
})(pts)
194197
}
198+
199+
if registry.PVCRef != nil {
200+
// PVC: Mount once per unique PVC (de-duplication)
201+
// Multiple registries can share the same PVC with different paths
202+
claimName := registry.PVCRef.ClaimName
203+
204+
if !mountedPVCs[claimName] {
205+
volumeName := fmt.Sprintf("registry-data-pvc-%s", claimName)
206+
207+
// Add the PVC volume
208+
WithVolume(corev1.Volume{
209+
Name: volumeName,
210+
VolumeSource: corev1.VolumeSource{
211+
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
212+
ClaimName: claimName,
213+
ReadOnly: true,
214+
},
215+
},
216+
})(pts)
217+
218+
// Mount at parent directory - PVC contains subdirectories
219+
WithVolumeMount(containerName, corev1.VolumeMount{
220+
Name: volumeName,
221+
MountPath: config.RegistryJSONFilePath,
222+
ReadOnly: true,
223+
})(pts)
224+
225+
mountedPVCs[claimName] = true
226+
}
227+
// else: PVC already mounted, registry will access its path within the shared mount
228+
}
195229
}
196230
}
197231
}

0 commit comments

Comments
 (0)