From 9ad31e53e6a2901ab0aca71901c29adf841424de Mon Sep 17 00:00:00 2001 From: Jon Burdo Date: Sat, 22 Nov 2025 18:50:29 -0500 Subject: [PATCH] Add PVC source support for MCPRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Jon Burdo --- cmd/thv-operator/REGISTRY.md | 85 +++++- .../api/v1alpha1/mcpregistry_types.go | 59 +++- .../api/v1alpha1/zz_generated.deepcopy.go | 20 ++ .../pkg/registryapi/config/config.go | 22 +- .../pkg/registryapi/config/config_test.go | 121 ++++++++ .../pkg/registryapi/podtemplatespec.go | 41 ++- .../pkg/registryapi/podtemplatespec_test.go | 78 +++++ .../mcp-registry/pvc_source_test.go | 240 +++++++++++++++ .../mcp-registry/registry_helpers.go | 10 + .../toolhive.stacklok.dev_mcpregistries.yaml | 47 ++- docs/operator/crd-api.md | 24 +- .../mcpregistry-multi-source.yaml | 261 +++++++++++++++++ .../mcp-registries/mcpregistry-pvc.yaml | 273 ++++++++++++++++++ 13 files changed, 1252 insertions(+), 29 deletions(-) create mode 100644 cmd/thv-operator/test-integration/mcp-registry/pvc_source_test.go create mode 100644 examples/operator/mcp-registries/mcpregistry-multi-source.yaml create mode 100644 examples/operator/mcp-registries/mcpregistry-pvc.yaml diff --git a/cmd/thv-operator/REGISTRY.md b/cmd/thv-operator/REGISTRY.md index 9bd4e0937..1c581d077 100644 --- a/cmd/thv-operator/REGISTRY.md +++ b/cmd/thv-operator/REGISTRY.md @@ -200,6 +200,89 @@ spec: - HTTPS is recommended for production use - Authentication support planned for future release +### PVC Source + +Store registry data in PersistentVolumeClaims for dynamic, persistent storage: + +```yaml +spec: + registries: + - name: production + format: toolhive + pvcRef: + claimName: registry-data-pvc + path: production/registry.json # Path within the PVC + syncPolicy: + interval: "1h" +``` + +**How PVC mounting works:** +- Each registry gets its own volume mount at `/config/registry/{registryName}/` +- File path becomes: `/config/registry/{registryName}/{path}` +- Multiple registries can share the same PVC by mounting it at different paths +- Consistent with ConfigMap source behavior (all sources use `{registryName}` pattern) + +**PVC Structure Examples:** + +Single PVC with multiple registries: +``` +PVC "shared-data": + /prod-data/registry.json + /dev-data/registry.json + +Registry "production": pvcRef: {claimName: shared-data, path: prod-data/registry.json} +→ Mounted at: /config/registry/production/ +→ File path: /config/registry/production/prod-data/registry.json + +Registry "development": pvcRef: {claimName: shared-data, path: dev-data/registry.json} +→ Mounted at: /config/registry/development/ +→ File path: /config/registry/development/dev-data/registry.json + +Note: Same PVC mounted twice at different paths, allowing independent registry access +``` + +**Populating PVC Data:** + +The PVC can be populated using: +- **Kubernetes Job** (recommended for initial setup) +- **Init container** in a deployment +- **Manual copy**: `kubectl cp registry.json pod:/path` +- **CSI driver** that provides pre-populated data +- **External sync process** that writes to the PVC + +Example populate Job: +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: populate-registry +spec: + template: + spec: + containers: + - name: populate + image: busybox + command: ["/bin/sh", "-c"] + args: + - | + mkdir -p /data/production + cat > /data/production/registry.json < 1 { - return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, or API) can be specified") + return nil, fmt.Errorf("only one source type (ConfigMapRef, Git, API, or PVCRef) can be specified") } // Build sync policy diff --git a/cmd/thv-operator/pkg/registryapi/config/config_test.go b/cmd/thv-operator/pkg/registryapi/config/config_test.go index 7829fef85..ba17918f7 100644 --- a/cmd/thv-operator/pkg/registryapi/config/config_test.go +++ b/cmd/thv-operator/pkg/registryapi/config/config_test.go @@ -1001,6 +1001,127 @@ func TestBuildConfig_MultipleRegistries(t *testing.T) { assert.Equal(t, []string{"server-*"}, config.Registries[1].Filter.Names.Include) } +func TestBuildConfig_PVCSource(t *testing.T) { + t.Parallel() + + t.Run("valid pvc source with default path", func(t *testing.T) { + t.Parallel() + mcpRegistry := &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registry", + }, + Spec: mcpv1alpha1.MCPRegistrySpec{ + Registries: []mcpv1alpha1.MCPRegistryConfig{ + { + Name: "pvc-registry", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: "registry-data-pvc", + }, + SyncPolicy: &mcpv1alpha1.SyncPolicy{ + Interval: "1h", + }, + }, + }, + }, + } + + manager := NewConfigManagerForTesting(mcpRegistry) + config, err := manager.BuildConfig() + + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, "test-registry", config.RegistryName) + require.Len(t, config.Registries, 1) + assert.Equal(t, "pvc-registry", config.Registries[0].Name) + assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Registries[0].Format) + require.NotNil(t, config.Registries[0].File) + // Path: /config/registry/{registryName}/{pvcRef.path} + assert.Equal(t, filepath.Join(RegistryJSONFilePath, "pvc-registry", RegistryJSONFileName), config.Registries[0].File.Path) + }) + + t.Run("valid pvc source with subdirectory path", func(t *testing.T) { + t.Parallel() + mcpRegistry := &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registry", + }, + Spec: mcpv1alpha1.MCPRegistrySpec{ + Registries: []mcpv1alpha1.MCPRegistryConfig{ + { + Name: "production-registry", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: "registry-data-pvc", + Path: "production/v1/servers.json", + }, + SyncPolicy: &mcpv1alpha1.SyncPolicy{ + Interval: "30m", + }, + }, + }, + }, + } + + manager := NewConfigManagerForTesting(mcpRegistry) + config, err := manager.BuildConfig() + + require.NoError(t, err) + require.NotNil(t, config) + require.Len(t, config.Registries, 1) + assert.Equal(t, "production-registry", config.Registries[0].Name) + require.NotNil(t, config.Registries[0].File) + // Path: /config/registry/{registryName}/{pvcRef.path} + assert.Equal(t, filepath.Join(RegistryJSONFilePath, "production-registry", "production/v1/servers.json"), config.Registries[0].File.Path) + }) + + t.Run("valid pvc source with filter", func(t *testing.T) { + t.Parallel() + mcpRegistry := &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-registry", + }, + Spec: mcpv1alpha1.MCPRegistrySpec{ + Registries: []mcpv1alpha1.MCPRegistryConfig{ + { + Name: "filtered-pvc", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: "registry-data-pvc", + Path: "registry.json", + }, + SyncPolicy: &mcpv1alpha1.SyncPolicy{ + Interval: "15m", + }, + Filter: &mcpv1alpha1.RegistryFilter{ + NameFilters: &mcpv1alpha1.NameFilter{ + Include: []string{"prod-*"}, + }, + Tags: &mcpv1alpha1.TagFilter{ + Include: []string{"production"}, + }, + }, + }, + }, + }, + } + + manager := NewConfigManagerForTesting(mcpRegistry) + config, err := manager.BuildConfig() + + require.NoError(t, err) + require.NotNil(t, config) + require.Len(t, config.Registries, 1) + assert.Equal(t, "filtered-pvc", config.Registries[0].Name) + require.NotNil(t, config.Registries[0].File) + // Verify filter is preserved + require.NotNil(t, config.Registries[0].Filter) + require.NotNil(t, config.Registries[0].Filter.Names) + assert.Equal(t, []string{"prod-*"}, config.Registries[0].Filter.Names.Include) + require.NotNil(t, config.Registries[0].Filter.Tags) + assert.Equal(t, []string{"production"}, config.Registries[0].Filter.Tags.Include) + }) +} func TestBuildConfig_DatabaseConfig(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/registryapi/podtemplatespec.go b/cmd/thv-operator/pkg/registryapi/podtemplatespec.go index 8d52f9543..39bf9bd8c 100644 --- a/cmd/thv-operator/pkg/registryapi/podtemplatespec.go +++ b/cmd/thv-operator/pkg/registryapi/podtemplatespec.go @@ -28,6 +28,11 @@ type PodTemplateSpecBuilder struct { defaultSpec *corev1.PodTemplateSpec } +// NewPodTemplateSpecBuilder creates a new PodTemplateSpecBuilder with an empty template. +func NewPodTemplateSpecBuilder() *PodTemplateSpecBuilder { + return NewPodTemplateSpecBuilderFrom(nil) +} + // NewPodTemplateSpecBuilderFrom creates a new PodTemplateSpecBuilder with a user-provided template. // The user template is deep-copied to avoid mutating the original. // Options applied via Apply() act as defaults - Build() will merge them with user values, @@ -187,13 +192,15 @@ func WithRegistryStorageMount(containerName string) PodTemplateSpecOption { } } -// WithRegistrySourceMounts creates volumes and mounts for all registry source ConfigMaps. -// This iterates through the registry sources and creates a volume and mount for each ConfigMapRef. +// WithRegistrySourceMounts creates volumes and mounts for all registry sources (ConfigMap and PVC). +// Each registry source (ConfigMap or PVC) gets its own volume and mount point +// at /config/registry/{registryName}/. Multiple registries can share the same PVC +// by mounting it at different paths. func WithRegistrySourceMounts(containerName string, registries []mcpv1alpha1.MCPRegistryConfig) PodTemplateSpecOption { return func(pts *corev1.PodTemplateSpec) { for _, registry := range registries { if registry.ConfigMapRef != nil { - // Create unique volume name for each ConfigMap source + // ConfigMap: Create unique volume per registry volumeName := fmt.Sprintf("registry-data-source-%s", registry.Name) // Add the ConfigMap volume @@ -215,8 +222,32 @@ func WithRegistrySourceMounts(containerName string, registries []mcpv1alpha1.MCP }, })(pts) - // Add the volume mount - // Mount path follows the pattern /config/registry/{registryName}/ + // Add the volume mount at registry-specific subdirectory + mountPath := filepath.Join(config.RegistryJSONFilePath, registry.Name) + WithVolumeMount(containerName, corev1.VolumeMount{ + Name: volumeName, + MountPath: mountPath, + ReadOnly: true, + })(pts) + } + + if registry.PVCRef != nil { + // PVC: Create unique volume per registry (same PVC can be mounted multiple times) + // Mount at /config/registry/{registryName}/ for consistent path structure + volumeName := fmt.Sprintf("registry-data-source-%s", registry.Name) + + // Add the PVC volume + WithVolume(corev1.Volume{ + Name: volumeName, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: registry.PVCRef.ClaimName, + ReadOnly: true, + }, + }, + })(pts) + + // Mount at registry-specific subdirectory mountPath := filepath.Join(config.RegistryJSONFilePath, registry.Name) WithVolumeMount(containerName, corev1.VolumeMount{ Name: volumeName, diff --git a/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go b/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go index 84f46e456..10a8ad0d6 100644 --- a/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go +++ b/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go @@ -308,6 +308,84 @@ func TestRegistryMountOptions(t *testing.T) { tt.assertions(t, pts) }) } + + // PVC source tests + t.Run("adds PVC volume and mount", func(t *testing.T) { + t.Parallel() + + options := []PodTemplateSpecOption{ + WithContainer(corev1.Container{Name: "registry-api"}), + WithRegistrySourceMounts("registry-api", []mcpv1alpha1.MCPRegistryConfig{ + { + Name: "pvc-source", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: "registry-data-pvc", + Path: "data/registry.json", + }, + }, + }), + } + + builder := NewPodTemplateSpecBuilderFrom(nil) + pts := builder.Apply(options...).Build() + + // Verify PVC volume was added + require.Len(t, pts.Spec.Volumes, 1) + volume := pts.Spec.Volumes[0] + assert.Equal(t, "registry-data-source-pvc-source", volume.Name) + require.NotNil(t, volume.PersistentVolumeClaim) + assert.Equal(t, "registry-data-pvc", volume.PersistentVolumeClaim.ClaimName) + assert.True(t, volume.PersistentVolumeClaim.ReadOnly) + + // Verify volume mount at registry name subdirectory + require.Len(t, pts.Spec.Containers[0].VolumeMounts, 1) + volumeMount := pts.Spec.Containers[0].VolumeMounts[0] + assert.Equal(t, "registry-data-source-pvc-source", volumeMount.Name) + assert.Equal(t, "/config/registry/pvc-source", volumeMount.MountPath) + assert.True(t, volumeMount.ReadOnly) + }) + + t.Run("allows multiple registries to share same PVC at different mount paths", func(t *testing.T) { + t.Parallel() + + options := []PodTemplateSpecOption{ + WithContainer(corev1.Container{Name: "registry-api"}), + WithRegistrySourceMounts("registry-api", []mcpv1alpha1.MCPRegistryConfig{ + { + Name: "production", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: "shared-pvc", + Path: "production/registry.json", + }, + }, + { + Name: "development", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: "shared-pvc", + Path: "development/registry.json", + }, + }, + }), + } + + builder := NewPodTemplateSpecBuilderFrom(nil) + pts := builder.Apply(options...).Build() + + // Verify TWO PVC volumes (one per registry, even though same PVC) + assert.Len(t, pts.Spec.Volumes, 2) + assert.Equal(t, "registry-data-source-production", pts.Spec.Volumes[0].Name) + assert.Equal(t, "shared-pvc", pts.Spec.Volumes[0].PersistentVolumeClaim.ClaimName) + assert.Equal(t, "registry-data-source-development", pts.Spec.Volumes[1].Name) + assert.Equal(t, "shared-pvc", pts.Spec.Volumes[1].PersistentVolumeClaim.ClaimName) + + // Verify TWO volume mounts at different paths (per registry name) + assert.Len(t, pts.Spec.Containers[0].VolumeMounts, 2) + assert.Equal(t, "/config/registry/production", pts.Spec.Containers[0].VolumeMounts[0].MountPath) + assert.Equal(t, "/config/registry/development", pts.Spec.Containers[0].VolumeMounts[1].MountPath) + }) } func TestBuildRegistryAPIContainer(t *testing.T) { diff --git a/cmd/thv-operator/test-integration/mcp-registry/pvc_source_test.go b/cmd/thv-operator/test-integration/mcp-registry/pvc_source_test.go new file mode 100644 index 000000000..6969debc1 --- /dev/null +++ b/cmd/thv-operator/test-integration/mcp-registry/pvc_source_test.go @@ -0,0 +1,240 @@ +package operator_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" +) + +var _ = Describe("MCPRegistry PVC Source", Label("k8s", "registry", "pvc"), func() { + var ( + ctx context.Context + registryHelper *MCPRegistryTestHelper + configMapHelper *ConfigMapTestHelper + statusHelper *StatusTestHelper + testNamespace string + testHelpers *serverConfigTestHelpers + ) + + BeforeEach(func() { + ctx = context.Background() + testNamespace = createTestNamespace(ctx) + + // Initialize helpers + registryHelper = NewMCPRegistryTestHelper(ctx, k8sClient, testNamespace) + configMapHelper = NewConfigMapTestHelper(ctx, k8sClient, testNamespace) + statusHelper = NewStatusTestHelper(ctx, k8sClient, testNamespace) + k8sHelper := NewK8sResourceTestHelper(ctx, k8sClient, testNamespace) + testHelpers = &serverConfigTestHelpers{ + ctx: ctx, + k8sClient: k8sClient, + testNamespace: testNamespace, + registryHelper: registryHelper, + k8sHelper: k8sHelper, + } + }) + + AfterEach(func() { + // Clean up test resources + Expect(registryHelper.CleanupRegistries()).To(Succeed()) + Expect(configMapHelper.CleanupConfigMaps()).To(Succeed()) + deleteTestNamespace(ctx, testNamespace) + }) + + Context("PVC Source Functionality", func() { + It("Should configure PVC volume and mount correctly", func() { + pvcName := "test-registry-data" + + By("Creating a PVC for registry data") + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("100Mi"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, pvc)).To(Succeed()) + + By("Creating MCPRegistry with PVC source") + registry := registryHelper.NewRegistryBuilder("test-pvc-registry"). + WithRegistryName("pvc-source"). + WithPVCSource(pvcName, "registry.json"). + WithSyncPolicy("1h"). + Create(registryHelper) + + By("Waiting for registry to initialize") + statusHelper.WaitForPhaseAny(registry.Name, []mcpv1alpha1.MCPRegistryPhase{ + mcpv1alpha1.MCPRegistryPhaseReady, + mcpv1alpha1.MCPRegistryPhasePending, + }, MediumTimeout) + + By("Verifying registry API deployment has PVC volume") + deployment := testHelpers.getDeploymentForRegistry(registry.Name) + + // Verify PVC volume exists + var pvcVolume *corev1.Volume + for i := range deployment.Spec.Template.Spec.Volumes { + if deployment.Spec.Template.Spec.Volumes[i].PersistentVolumeClaim != nil { + pvcVolume = &deployment.Spec.Template.Spec.Volumes[i] + break + } + } + Expect(pvcVolume).ToNot(BeNil(), "PVC volume should be configured") + Expect(pvcVolume.PersistentVolumeClaim.ClaimName).To(Equal(pvcName)) + Expect(pvcVolume.PersistentVolumeClaim.ReadOnly).To(BeTrue()) + + By("Verifying container has PVC volume mount") + container := deployment.Spec.Template.Spec.Containers[0] + var pvcMount *corev1.VolumeMount + for i := range container.VolumeMounts { + if container.VolumeMounts[i].Name == pvcVolume.Name { + pvcMount = &container.VolumeMounts[i] + break + } + } + Expect(pvcMount).ToNot(BeNil(), "PVC volume mount should be configured") + Expect(pvcMount.MountPath).To(Equal("/config/registry/pvc-source")) + Expect(pvcMount.ReadOnly).To(BeTrue()) + + By("Verifying registry server config ConfigMap is created") + serverConfigMap := testHelpers.waitForAndGetServerConfigMap(registry.Name) + + By("Validating config includes registry name in path") + configYAML, exists := serverConfigMap.Data["config.yaml"] + Expect(exists).To(BeTrue()) + // Path should be /config/registry/{registryName}/{PVCRef.Path} + expectedPath := "/config/registry/pvc-source/registry.json" + Expect(configYAML).To(ContainSubstring(expectedPath)) + }) + }) + + Context("Single PVC with Multiple Registries", func() { + It("Should support multiple registries from a single shared PVC", func() { + pvcName := "shared-registry-data" + + By("Creating a single PVC for multiple registry sources") + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: pvcName, + Namespace: testNamespace, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteOnce, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("100Mi"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, pvc)).To(Succeed()) + + By("Creating MCPRegistry with two registries from the same PVC") + registry := &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-shared-pvc-registry", + Namespace: testNamespace, + Labels: map[string]string{ + "test.toolhive.io/suite": "operator-e2e", + }, + }, + Spec: mcpv1alpha1.MCPRegistrySpec{ + Registries: []mcpv1alpha1.MCPRegistryConfig{ + { + Name: "production", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: pvcName, + Path: "production/registry.json", + }, + SyncPolicy: &mcpv1alpha1.SyncPolicy{ + Interval: "2h", + }, + }, + { + Name: "development", + Format: mcpv1alpha1.RegistryFormatToolHive, + PVCRef: &mcpv1alpha1.PVCSource{ + ClaimName: pvcName, + Path: "development/registry.json", + }, + SyncPolicy: &mcpv1alpha1.SyncPolicy{ + Interval: "30m", + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, registry)).To(Succeed()) + + By("Waiting for registry to initialize") + statusHelper.WaitForPhaseAny(registry.Name, []mcpv1alpha1.MCPRegistryPhase{ + mcpv1alpha1.MCPRegistryPhaseReady, + mcpv1alpha1.MCPRegistryPhasePending, + }, MediumTimeout) + + By("Verifying registry server config ConfigMap contains both registries") + serverConfigMap := testHelpers.waitForAndGetServerConfigMap(registry.Name) + + configYAML, exists := serverConfigMap.Data["config.yaml"] + Expect(exists).To(BeTrue()) + Expect(configYAML).To(ContainSubstring("production")) + Expect(configYAML).To(ContainSubstring("development")) + + // Verify file paths use registry names as subdirectories to prevent conflicts + // Pattern: /config/registry/{registryName}/{pvcRef.path} + expectedProdPath := "/config/registry/production/production/registry.json" + expectedDevPath := "/config/registry/development/development/registry.json" + Expect(configYAML).To(ContainSubstring(expectedProdPath)) + Expect(configYAML).To(ContainSubstring(expectedDevPath)) + + By("Verifying deployment has TWO PVC volumes (one per registry)") + deployment := testHelpers.getDeploymentForRegistry(registry.Name) + + // Find PVC volumes - should have 2 volumes (one per registry), both pointing to same PVC + pvcVolumes := make(map[string]string) // volume name -> PVC claim name + for _, vol := range deployment.Spec.Template.Spec.Volumes { + if vol.PersistentVolumeClaim != nil { + pvcVolumes[vol.Name] = vol.PersistentVolumeClaim.ClaimName + } + } + + // Should have 2 PVC volumes (one per registry), both pointing to same PVC + Expect(pvcVolumes).To(HaveLen(2), "Should have 2 PVC volumes (one per registry)") + Expect(pvcVolumes).To(HaveKey("registry-data-source-production")) + Expect(pvcVolumes).To(HaveKey("registry-data-source-development")) + Expect(pvcVolumes["registry-data-source-production"]).To(Equal(pvcName)) + Expect(pvcVolumes["registry-data-source-development"]).To(Equal(pvcName)) + + By("Verifying each registry has its own mount point") + container := deployment.Spec.Template.Spec.Containers[0] + mountPaths := make(map[string]string) // volume name -> mount path + for _, mount := range container.VolumeMounts { + if _, isPVC := pvcVolumes[mount.Name]; isPVC { + mountPaths[mount.Name] = mount.MountPath + } + } + + // Verify each registry mounted at its own subdirectory (prevents path conflicts) + Expect(mountPaths["registry-data-source-production"]).To(Equal("/config/registry/production")) + Expect(mountPaths["registry-data-source-development"]).To(Equal("/config/registry/development")) + }) + }) +}) diff --git a/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go b/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go index 1ae2d77cb..77a9f496a 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go @@ -103,6 +103,16 @@ func (rb *RegistryBuilder) WithAPISource(endpoint string) *RegistryBuilder { return rb } +// WithPVCSource configures the registry with a PVC source +func (rb *RegistryBuilder) WithPVCSource(claimName, path string) *RegistryBuilder { + registryConfig := rb.getCurrentRegistryConfig() + registryConfig.PVCRef = &mcpv1alpha1.PVCSource{ + ClaimName: claimName, + Path: path, + } + return rb +} + // WithRegistryName sets the name for the current registry config func (rb *RegistryBuilder) WithRegistryName(name string) *RegistryBuilder { registryConfig := rb.getCurrentRegistryConfig() diff --git a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpregistries.yaml b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpregistries.yaml index bda9def51..d7c5d0b7c 100644 --- a/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpregistries.yaml +++ b/deploy/charts/operator-crds/crds/toolhive.stacklok.dev_mcpregistries.yaml @@ -165,7 +165,7 @@ spec: api: description: |- API defines the API source configuration - Mutually exclusive with ConfigMapRef and Git + Mutually exclusive with ConfigMapRef, Git, and PVCRef properties: endpoint: description: |- @@ -185,7 +185,7 @@ spec: configMapRef: description: |- ConfigMapRef defines the ConfigMap source configuration - Mutually exclusive with Git and API + Mutually exclusive with Git, API, and PVCRef properties: key: description: The key to select. @@ -250,7 +250,7 @@ spec: git: description: |- Git defines the Git repository source configuration - Mutually exclusive with ConfigMapRef and API + Mutually exclusive with ConfigMapRef, API, and PVCRef properties: branch: description: Branch is the Git branch to use (mutually exclusive @@ -286,6 +286,47 @@ spec: within the MCPRegistry minLength: 1 type: string + pvcRef: + description: |- + PVCRef defines the PersistentVolumeClaim source configuration + Mutually exclusive with ConfigMapRef, Git, and API + properties: + claimName: + description: ClaimName is the name of the PersistentVolumeClaim + minLength: 1 + type: string + path: + default: registry.json + description: |- + Path is the relative path to the registry file within the PVC. + The PVC is mounted at /config/registry/{registryName}/. + The full file path becomes: /config/registry/{registryName}/{path} + + This design: + - Each registry gets its own mount point (consistent with ConfigMap sources) + - Multiple registries can share the same PVC by mounting it at different paths + - Users control PVC organization freely via the path field + + Examples: + Registry "production" using PVC "shared-data" with path "prod/registry.json": + PVC contains /prod/registry.json → accessed at /config/registry/production/prod/registry.json + + Registry "development" using SAME PVC "shared-data" with path "dev/registry.json": + PVC contains /dev/registry.json → accessed at /config/registry/development/dev/registry.json + (Same PVC, different mount path) + + Registry "staging" using DIFFERENT PVC "other-pvc" with path "registry.json": + PVC contains /registry.json → accessed at /config/registry/staging/registry.json + (Different PVC, independent mount) + + Registry "team-a" with path "v1/servers.json": + PVC contains /v1/servers.json → accessed at /config/registry/team-a/v1/servers.json + (Subdirectories allowed in path) + pattern: ^.*\.json$ + type: string + required: + - claimName + type: object syncPolicy: description: |- SyncPolicy defines the automatic synchronization behavior for this registry. diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 735a60127..c8f358cea 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -735,9 +735,10 @@ _Appears in:_ | --- | --- | --- | --- | | `name` _string_ | Name is a unique identifier for this registry configuration within the MCPRegistry | | MinLength: 1
Required: \{\}
| | `format` _string_ | Format is the data format (toolhive, upstream) | toolhive | Enum: [toolhive upstream]
| -| `configMapRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | ConfigMapRef defines the ConfigMap source configuration
Mutually exclusive with Git and API | | | -| `git` _[GitSource](#gitsource)_ | Git defines the Git repository source configuration
Mutually exclusive with ConfigMapRef and API | | | -| `api` _[APISource](#apisource)_ | API defines the API source configuration
Mutually exclusive with ConfigMapRef and Git | | | +| `configMapRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | ConfigMapRef defines the ConfigMap source configuration
Mutually exclusive with Git, API, and PVCRef | | | +| `git` _[GitSource](#gitsource)_ | Git defines the Git repository source configuration
Mutually exclusive with ConfigMapRef, API, and PVCRef | | | +| `api` _[APISource](#apisource)_ | API defines the API source configuration
Mutually exclusive with ConfigMapRef, Git, and PVCRef | | | +| `pvcRef` _[PVCSource](#pvcsource)_ | PVCRef defines the PersistentVolumeClaim source configuration
Mutually exclusive with ConfigMapRef, Git, and API | | | | `syncPolicy` _[SyncPolicy](#syncpolicy)_ | SyncPolicy defines the automatic synchronization behavior for this registry.
If specified, enables automatic synchronization at the given interval.
Manual synchronization is always supported via annotation-based triggers
regardless of this setting. | | | | `filter` _[RegistryFilter](#registryfilter)_ | Filter defines include/exclude patterns for registry content | | | @@ -1333,6 +1334,23 @@ _Appears in:_ | `backends` _object (keys:string, values:[BackendAuthConfig](#backendauthconfig))_ | Backends defines per-backend authentication overrides
Works in all modes (discovered, inline) | | | +#### PVCSource + + + +PVCSource defines PersistentVolumeClaim source configuration + + + +_Appears in:_ +- [MCPRegistryConfig](#mcpregistryconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `claimName` _string_ | ClaimName is the name of the PersistentVolumeClaim | | MinLength: 1
Required: \{\}
| +| `path` _string_ | Path is the relative path to the registry file within the PVC.
The PVC is mounted at /config/registry/\{registryName\}/.
The full file path becomes: /config/registry/\{registryName\}/\{path\}
This design:
- Each registry gets its own mount point (consistent with ConfigMap sources)
- Multiple registries can share the same PVC by mounting it at different paths
- Users control PVC organization freely via the path field
Examples:
Registry "production" using PVC "shared-data" with path "prod/registry.json":
PVC contains /prod/registry.json → accessed at /config/registry/production/prod/registry.json
Registry "development" using SAME PVC "shared-data" with path "dev/registry.json":
PVC contains /dev/registry.json → accessed at /config/registry/development/dev/registry.json
(Same PVC, different mount path)
Registry "staging" using DIFFERENT PVC "other-pvc" with path "registry.json":
PVC contains /registry.json → accessed at /config/registry/staging/registry.json
(Different PVC, independent mount)
Registry "team-a" with path "v1/servers.json":
PVC contains /v1/servers.json → accessed at /config/registry/team-a/v1/servers.json
(Subdirectories allowed in path) | registry.json | Pattern: `^.*\.json$`
| + + #### PermissionProfileRef diff --git a/examples/operator/mcp-registries/mcpregistry-multi-source.yaml b/examples/operator/mcp-registries/mcpregistry-multi-source.yaml new file mode 100644 index 000000000..747fed89c --- /dev/null +++ b/examples/operator/mcp-registries/mcpregistry-multi-source.yaml @@ -0,0 +1,261 @@ +# Example MCPRegistry with Multiple Source Types +# +# This example demonstrates: +# 1. A single MCPRegistry resource managing multiple registry sources +# 2. One ConfigMap-based registry (immutable, part of deployment) +# 3. Two PVC-based registries from a SINGLE shared PVC +# 4. Independent sync policies and filters per registry +# +# This shows the flexibility of mixing source types: +# - ConfigMap: For static, version-controlled registry data +# - PVC: For dynamic registry data that can be updated independently +# +# Shared PVC structure: +# /monitoring/registry.json - Monitoring and observability tools +# /ai-ml/registry.json - AI/ML tools +--- +# ConfigMap for production tools (static, immutable) +apiVersion: v1 +kind: ConfigMap +metadata: + name: production-registry-data + namespace: toolhive-system +data: + registry.json: | + { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/schema.json", + "version": "1.0.0", + "last_updated": "2025-11-26T12:00:00Z", + "servers": { + "prod-database": { + "description": "Production database MCP server", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": ["query", "migrate", "backup"], + "metadata": { + "stars": 2000, + "pulls": 1000, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/example/db-mcp-server", + "tags": ["database", "production", "data"], + "image": "ghcr.io/example/db-server:latest", + "permissions": { + "network": { + "outbound": {} + } + } + } + } + } +--- +# Single PVC containing multiple registry subdirectories +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shared-dynamic-registries + namespace: toolhive-system +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 100Mi +--- +# MCPRegistry with three different sources: 1 ConfigMap + 2 registries from 1 PVC +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPRegistry +metadata: + name: multi-source-registry + namespace: toolhive-system +spec: + displayName: "Multi-Source Registry (ConfigMap + Shared PVC)" + registries: + # Registry 1: ConfigMap source (static production tools) + - name: production-tools + format: toolhive + configMapRef: + name: production-registry-data + key: registry.json + syncPolicy: + interval: "2h" + filter: + tags: + include: + - "production" + - "database" + + # Registry 2: PVC source (monitoring tools) - from shared PVC + - name: monitoring + format: toolhive + pvcRef: + claimName: shared-dynamic-registries + path: monitoring/registry.json + syncPolicy: + interval: "1h" + filter: + tags: + include: + - "monitoring" + - "observability" + + # Registry 3: PVC source (AI/ML tools) - from SAME shared PVC + - name: ai-ml + format: toolhive + pvcRef: + claimName: shared-dynamic-registries + path: ai-ml/registry.json + syncPolicy: + interval: "30m" + filter: + names: + include: + - "*ai*" + - "*ml*" + tags: + include: + - "ai" + - "machine-learning" +--- +# Job to populate the shared PVC with multiple registry directories +# This creates /monitoring and /ai-ml subdirectories with their respective data +apiVersion: batch/v1 +kind: Job +metadata: + name: populate-shared-pvc + namespace: toolhive-system +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: populate + image: busybox:latest + command: + - /bin/sh + - -c + - | + # Create subdirectories matching registry names + mkdir -p /data/monitoring + mkdir -p /data/ai-ml + + # Populate monitoring registry + cat > /data/monitoring/registry.json <<'EOF' + { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/schema.json", + "version": "1.0.0", + "last_updated": "2025-11-26T12:00:00Z", + "servers": { + "prometheus-exporter": { + "description": "Prometheus metrics exporter", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": ["export_metrics", "query_metrics"], + "metadata": { + "stars": 1800, + "pulls": 900, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/example/prom-mcp", + "tags": ["monitoring", "observability", "metrics"], + "image": "ghcr.io/example/prom-mcp:latest", + "permissions": { + "network": { + "outbound": {} + } + } + }, + "log-analyzer": { + "description": "Log analysis and aggregation", + "tier": "Community", + "status": "Active", + "transport": "stdio", + "tools": ["analyze_logs", "search_logs", "aggregate"], + "metadata": { + "stars": 650, + "pulls": 200, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/example/log-mcp", + "tags": ["monitoring", "observability", "logs"], + "image": "ghcr.io/example/log-mcp:latest", + "permissions": { + "network": { + "outbound": {} + } + } + } + } + } + EOF + + # Populate AI/ML registry + cat > /data/ai-ml/registry.json <<'EOF' + { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/schema.json", + "version": "1.0.0", + "last_updated": "2025-11-26T12:00:00Z", + "servers": { + "ml-inference": { + "description": "Machine learning inference server", + "tier": "Community", + "status": "Active", + "transport": "stdio", + "tools": ["predict", "train", "evaluate"], + "metadata": { + "stars": 1200, + "pulls": 400, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/example/ml-mcp", + "tags": ["ai", "machine-learning", "inference"], + "image": "ghcr.io/example/ml-mcp:latest", + "permissions": { + "network": { + "outbound": {} + } + } + }, + "ai-embeddings": { + "description": "Generate and manage AI embeddings", + "tier": "Community", + "status": "Active", + "transport": "stdio", + "tools": ["generate_embedding", "search_similar"], + "metadata": { + "stars": 890, + "pulls": 250, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/example/embeddings-mcp", + "tags": ["ai", "machine-learning", "embeddings"], + "image": "ghcr.io/example/embeddings-mcp:latest", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".openai.com" + ], + "allow_port": [ + 443 + ] + } + } + } + } + } + } + EOF + + echo "Shared PVC populated successfully" + echo "Directory structure:" + ls -lR /data + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + persistentVolumeClaim: + claimName: shared-dynamic-registries diff --git a/examples/operator/mcp-registries/mcpregistry-pvc.yaml b/examples/operator/mcp-registries/mcpregistry-pvc.yaml new file mode 100644 index 000000000..6d4d54a10 --- /dev/null +++ b/examples/operator/mcp-registries/mcpregistry-pvc.yaml @@ -0,0 +1,273 @@ +# Example MCPRegistry using a single PVC with multiple registries +# +# This example demonstrates: +# 1. Creating a single PVC to store multiple registry sources +# 2. Configuring MCPRegistry with multiple registries from the same PVC +# 3. Each registry in its own subdirectory within the PVC +# 4. Independent sync policies and filters per registry +# +# PVC structure (can organize data however you want): +# /prod-data/registry.json - Production servers +# /dev-data/registry.json - Development servers +# +# How it works: +# - Registry "production" mounts PVC at /config/registry/production/ +# - Reads file from pvcRef.path: prod-data/registry.json +# - Final path: /config/registry/production/prod-data/registry.json +# +# - Registry "development" mounts SAME PVC at /config/registry/development/ +# - Reads file from pvcRef.path: dev-data/registry.json +# - Final path: /config/registry/development/dev-data/registry.json +# +# Both registries see the entire PVC but from different mount points! +# +# The PVC can be populated by: +# - An init container or Job (example below) +# - A separate process that writes to the PVC +# - Manual copy using kubectl cp +# - A CSI driver that provides the data +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shared-registry-data + namespace: toolhive-system +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 200Mi +--- +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPRegistry +metadata: + name: pvc-multi-registry + namespace: toolhive-system +spec: + displayName: "Multi-Registry from Single PVC" + registries: + # Production registry - reads from prod-data/ subdirectory in PVC + - name: production + format: toolhive + pvcRef: + claimName: shared-registry-data + path: prod-data/registry.json + syncPolicy: + interval: "2h" + filter: + tags: + include: + - "production" + - "stable" + + # Development registry - reads from dev-data/ subdirectory in PVC + - name: development + format: toolhive + pvcRef: + claimName: shared-registry-data + path: dev-data/registry.json + syncPolicy: + interval: "30m" + filter: + tags: + include: + - "development" + - "testing" +--- +# Example Job to populate the shared PVC with multiple registry directories +# This job creates the directory structure and writes registry data for both registries +apiVersion: batch/v1 +kind: Job +metadata: + name: populate-shared-registry-pvc + namespace: toolhive-system +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: populate + image: busybox:latest + command: + - /bin/sh + - -c + - | + # Create subdirectories for different registry data + # Note: These directory names don't need to match registry names + mkdir -p /data/prod-data + mkdir -p /data/dev-data + + # Write production registry data + cat > /data/prod-data/registry.json <<'EOF' + { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/schema.json", + "version": "1.0.0", + "last_updated": "2025-11-26T12:00:00Z", + "servers": { + "prod-filesystem": { + "description": "Production-ready filesystem operations server", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "read_file", + "write_file", + "list_directory", + "create_directory", + "backup_files" + ], + "metadata": { + "stars": 1500, + "pulls": 500, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/modelcontextprotocol/servers", + "tags": [ + "filesystem", + "files", + "storage", + "production", + "stable" + ], + "image": "docker.io/mcp/filesystem:latest", + "permissions": { + "network": { + "outbound": {} + } + }, + "args": [ + "/projects" + ] + }, + "github-api": { + "description": "Production GitHub API integration", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": [ + "create_issue", + "create_pull_request", + "search_repositories" + ], + "metadata": { + "stars": 2200, + "pulls": 800, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/github/github-mcp-server", + "tags": [ + "github", + "api", + "repository", + "production", + "stable" + ], + "image": "ghcr.io/github/github-mcp-server:latest", + "permissions": { + "network": { + "outbound": { + "allow_host": [ + ".github.com", + ".githubusercontent.com" + ], + "allow_port": [ + 443 + ] + } + } + }, + "env_vars": [ + { + "name": "GITHUB_PERSONAL_ACCESS_TOKEN", + "description": "GitHub personal access token with appropriate permissions", + "required": true, + "secret": true + } + ] + } + } + } + EOF + + # Write development registry data + cat > /data/dev-data/registry.json <<'EOF' + { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/schema.json", + "version": "1.0.0", + "last_updated": "2025-11-26T12:00:00Z", + "servers": { + "dev-filesystem": { + "description": "Development version of filesystem operations server", + "tier": "Community", + "status": "Active", + "transport": "stdio", + "tools": [ + "read_file", + "write_file", + "list_directory" + ], + "metadata": { + "stars": 800, + "pulls": 200, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/modelcontextprotocol/servers", + "tags": [ + "filesystem", + "files", + "development", + "testing" + ], + "image": "docker.io/mcp/filesystem:dev", + "permissions": { + "network": { + "outbound": {} + } + } + }, + "test-runner": { + "description": "Automated testing and quality assurance server", + "tier": "Community", + "status": "Active", + "transport": "stdio", + "tools": [ + "run_tests", + "analyze_coverage", + "generate_report" + ], + "metadata": { + "stars": 300, + "pulls": 80, + "last_updated": "2025-11-26T12:00:00Z" + }, + "repository_url": "https://github.com/testing/test-runner-mcp", + "tags": [ + "testing", + "quality", + "automation", + "development" + ], + "image": "testing/test-runner:latest", + "permissions": { + "network": { + "outbound": {} + } + } + } + } + } + EOF + + echo "Registry data written successfully" + echo "Production registry:" + ls -lh /data/prod-data/registry.json + echo "Development registry:" + ls -lh /data/dev-data/registry.json + volumeMounts: + - name: data + mountPath: /data + volumes: + - name: data + persistentVolumeClaim: + claimName: shared-registry-data