Skip to content

Commit 822def6

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 c02b88b commit 822def6

File tree

11 files changed

+1236
-22
lines changed

11 files changed

+1236
-22
lines changed

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

Lines changed: 81 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,20 @@ 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 path to the registry file within the PVC
150+
// +kubebuilder:validation:Pattern=^.*\.json$
151+
// +kubebuilder:default=registry.json
152+
// +optional
153+
Path string `json:"path,omitempty"`
154+
}
155+
134156
// SyncPolicy defines automatic synchronization behavior.
135157
// When specified, enables automatic synchronization at the given interval.
136158
// Manual synchronization via annotation-based triggers is always available
@@ -392,6 +414,62 @@ func (r *MCPRegistry) GetAPIResourceName() string {
392414
return fmt.Sprintf("%s-api", r.Name)
393415
}
394416

417+
// IsConfigMapRegistrySource returns true if any registry source is a configmap
418+
func (r *MCPRegistry) IsConfigMapRegistrySource() bool {
419+
for _, registry := range r.Spec.Registries {
420+
if registry.ConfigMapRef != nil {
421+
return true
422+
}
423+
}
424+
return false
425+
}
426+
427+
// GetConfigMapSourceName returns the name of the first configmap source
428+
// if present, otherwise returns an empty string
429+
func (r *MCPRegistry) GetConfigMapSourceName() string {
430+
for _, registry := range r.Spec.Registries {
431+
if registry.ConfigMapRef != nil {
432+
return registry.ConfigMapRef.Name
433+
}
434+
}
435+
return ""
436+
}
437+
438+
// IsPVCRegistrySource checks if any registry uses a PVC source
439+
func (r *MCPRegistry) IsPVCRegistrySource() bool {
440+
for _, registry := range r.Spec.Registries {
441+
if registry.PVCRef != nil {
442+
return true
443+
}
444+
}
445+
return false
446+
}
447+
448+
// GetPVCSourceName returns the name of the first PVC source
449+
// if present, otherwise returns an empty string
450+
func (r *MCPRegistry) GetPVCSourceName() string {
451+
for _, registry := range r.Spec.Registries {
452+
if registry.PVCRef != nil {
453+
return registry.PVCRef.ClaimName
454+
}
455+
}
456+
return ""
457+
}
458+
459+
// GetPVCSourcePath returns the path within the PVC for the first PVC source
460+
// if present, otherwise returns "registry.json" as default
461+
func (r *MCPRegistry) GetPVCSourcePath() string {
462+
for _, registry := range r.Spec.Registries {
463+
if registry.PVCRef != nil {
464+
if registry.PVCRef.Path != "" {
465+
return registry.PVCRef.Path
466+
}
467+
return "registry.json"
468+
}
469+
}
470+
return "registry.json"
471+
}
472+
395473
// DeriveOverallPhase determines the overall MCPRegistry phase based on sync and API status
396474
func (r *MCPRegistry) DeriveOverallPhase() MCPRegistryPhase {
397475
syncStatus := r.Status.SyncStatus

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: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,16 @@ func validateRegistryNames(registries []mcpv1alpha1.MCPRegistryConfig) error {
247247
}
248248

249249
func buildFilePath(registryName string) *FileConfig {
250+
return buildFilePathWithFilename(registryName, RegistryJSONFileName)
251+
}
252+
253+
func buildFilePathWithFilename(registryName, filename string) *FileConfig {
250254
return &FileConfig{
251-
Path: filepath.Join(RegistryJSONFilePath, registryName, RegistryJSONFileName),
255+
Path: filepath.Join(RegistryJSONFilePath, registryName, filename),
252256
}
253257
}
254258

259+
//nolint:gocyclo // Complexity is acceptable for handling multiple source types
255260
func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*RegistryConfig, error) {
256261
if registrySpec.Name == "" {
257262
return nil, fmt.Errorf("registry name is required")
@@ -292,12 +297,24 @@ func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*Registry
292297
}
293298
registryConfig.API = apiConfig
294299
}
300+
if registrySpec.PVCRef != nil {
301+
sourceCount++
302+
// we use the file source config for PVC sources
303+
// because the PVC will be mounted as a file in the registry server container.
304+
// this stops the registry server worrying about PVC sources when all it has to do
305+
// is read the file on startup
306+
filename := RegistryJSONFileName
307+
if registrySpec.PVCRef.Path != "" {
308+
filename = registrySpec.PVCRef.Path
309+
}
310+
registryConfig.File = buildFilePathWithFilename(registrySpec.Name, filename)
311+
}
295312

296313
if sourceCount == 0 {
297-
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, or API) must be specified")
314+
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, API, or PVCRef) must be specified")
298315
}
299316
if sourceCount > 1 {
300-
return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, or API) can be specified")
317+
return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, API, or PVCRef) can be specified")
301318
}
302319

303320
// 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
@@ -1000,3 +1000,124 @@ 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+
assert.Equal(t, filepath.Join(RegistryJSONFilePath, "pvc-registry", RegistryJSONFileName), config.Registries[0].File.Path)
1040+
})
1041+
1042+
t.Run("valid pvc source with custom path", func(t *testing.T) {
1043+
t.Parallel()
1044+
mcpRegistry := &mcpv1alpha1.MCPRegistry{
1045+
ObjectMeta: metav1.ObjectMeta{
1046+
Name: "test-registry",
1047+
},
1048+
Spec: mcpv1alpha1.MCPRegistrySpec{
1049+
Registries: []mcpv1alpha1.MCPRegistryConfig{
1050+
{
1051+
Name: "custom-pvc",
1052+
Format: mcpv1alpha1.RegistryFormatToolHive,
1053+
PVCRef: &mcpv1alpha1.PVCSource{
1054+
ClaimName: "registry-data-pvc",
1055+
Path: "custom-registry.json",
1056+
},
1057+
SyncPolicy: &mcpv1alpha1.SyncPolicy{
1058+
Interval: "30m",
1059+
},
1060+
},
1061+
},
1062+
},
1063+
}
1064+
1065+
manager := NewConfigManagerForTesting(mcpRegistry)
1066+
config, err := manager.BuildConfig()
1067+
1068+
require.NoError(t, err)
1069+
require.NotNil(t, config)
1070+
require.Len(t, config.Registries, 1)
1071+
assert.Equal(t, "custom-pvc", config.Registries[0].Name)
1072+
require.NotNil(t, config.Registries[0].File)
1073+
// Path uses the custom path specified in PVCRef.Path
1074+
assert.Equal(t, filepath.Join(RegistryJSONFilePath, "custom-pvc", "custom-registry.json"), config.Registries[0].File.Path)
1075+
})
1076+
1077+
t.Run("valid pvc source with filter", func(t *testing.T) {
1078+
t.Parallel()
1079+
mcpRegistry := &mcpv1alpha1.MCPRegistry{
1080+
ObjectMeta: metav1.ObjectMeta{
1081+
Name: "test-registry",
1082+
},
1083+
Spec: mcpv1alpha1.MCPRegistrySpec{
1084+
Registries: []mcpv1alpha1.MCPRegistryConfig{
1085+
{
1086+
Name: "filtered-pvc",
1087+
Format: mcpv1alpha1.RegistryFormatToolHive,
1088+
PVCRef: &mcpv1alpha1.PVCSource{
1089+
ClaimName: "registry-data-pvc",
1090+
Path: "registry.json",
1091+
},
1092+
SyncPolicy: &mcpv1alpha1.SyncPolicy{
1093+
Interval: "15m",
1094+
},
1095+
Filter: &mcpv1alpha1.RegistryFilter{
1096+
NameFilters: &mcpv1alpha1.NameFilter{
1097+
Include: []string{"prod-*"},
1098+
},
1099+
Tags: &mcpv1alpha1.TagFilter{
1100+
Include: []string{"production"},
1101+
},
1102+
},
1103+
},
1104+
},
1105+
},
1106+
}
1107+
1108+
manager := NewConfigManagerForTesting(mcpRegistry)
1109+
config, err := manager.BuildConfig()
1110+
1111+
require.NoError(t, err)
1112+
require.NotNil(t, config)
1113+
require.Len(t, config.Registries, 1)
1114+
assert.Equal(t, "filtered-pvc", config.Registries[0].Name)
1115+
require.NotNil(t, config.Registries[0].File)
1116+
// Verify filter is preserved
1117+
require.NotNil(t, config.Registries[0].Filter)
1118+
require.NotNil(t, config.Registries[0].Filter.Names)
1119+
assert.Equal(t, []string{"prod-*"}, config.Registries[0].Filter.Names.Include)
1120+
require.NotNil(t, config.Registries[0].Filter.Tags)
1121+
assert.Equal(t, []string{"production"}, config.Registries[0].Filter.Tags.Include)
1122+
})
1123+
}

0 commit comments

Comments
 (0)