Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion cmd/thv-operator/REGISTRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
{"version": "1.0.0", "servers": {...}}
EOF
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: registry-data-pvc
restartPolicy: OnFailure
```

**See Also:**
- Complete PVC example: [examples/operator/mcp-registries/mcpregistry-pvc.yaml](../../examples/operator/mcp-registries/mcpregistry-pvc.yaml)
- Multi-source example: [examples/operator/mcp-registries/mcpregistry-multi-source.yaml](../../examples/operator/mcp-registries/mcpregistry-multi-source.yaml)

### Registry Formats

**ToolHive Format** (default):
Expand Down Expand Up @@ -557,4 +640,4 @@ Each registry configuration must have a unique `name` within the MCPRegistry.
- [MCPServer Documentation](README.md#usage)
- [Operator Installation](../../docs/kind/deploying-toolhive-operator.md)
- [Registry Examples](../../examples/operator/mcp-registries/)
- [Registry Schema](../../pkg/registry/data/schema.json)
- [Registry Schema](../../pkg/registry/data/schema.json)
59 changes: 45 additions & 14 deletions cmd/thv-operator/api/v1alpha1/mcpregistry_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,6 @@ import (
"k8s.io/apimachinery/pkg/runtime"
)

const (
// RegistrySourceTypeConfigMap is the type for registry data stored in ConfigMaps
RegistrySourceTypeConfigMap = "configmap"

// RegistrySourceTypeGit is the type for registry data stored in Git repositories
RegistrySourceTypeGit = "git"

// RegistrySourceTypeAPI is the type for registry data fetched from API endpoints
RegistrySourceTypeAPI = "api"
)

// Registry formats
const (
// RegistryFormatToolHive is the native ToolHive registry format
Expand Down Expand Up @@ -83,20 +72,25 @@ type MCPRegistryConfig struct {
Format string `json:"format,omitempty"`

// ConfigMapRef defines the ConfigMap source configuration
// Mutually exclusive with Git and API
// Mutually exclusive with Git, API, and PVCRef
// +optional
ConfigMapRef *corev1.ConfigMapKeySelector `json:"configMapRef,omitempty"`

// Git defines the Git repository source configuration
// Mutually exclusive with ConfigMapRef and API
// Mutually exclusive with ConfigMapRef, API, and PVCRef
// +optional
Git *GitSource `json:"git,omitempty"`

// API defines the API source configuration
// Mutually exclusive with ConfigMapRef and Git
// Mutually exclusive with ConfigMapRef, Git, and PVCRef
// +optional
API *APISource `json:"api,omitempty"`

// PVCRef defines the PersistentVolumeClaim source configuration
// Mutually exclusive with ConfigMapRef, Git, and API
// +optional
PVCRef *PVCSource `json:"pvcRef,omitempty"`

// 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
Expand Down Expand Up @@ -156,6 +150,43 @@ type APISource struct {
Endpoint string `json:"endpoint"`
}

// PVCSource defines PersistentVolumeClaim source configuration
type PVCSource struct {
// ClaimName is the name of the PersistentVolumeClaim
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinLength=1
ClaimName string `json:"claimName"`

// 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)
// +kubebuilder:validation:Pattern=^.*\.json$
// +kubebuilder:default=registry.json
// +optional
Path string `json:"path,omitempty"`
}

// SyncPolicy defines automatic synchronization behavior.
// When specified, enables automatic synchronization at the given interval.
// Manual synchronization via annotation-based triggers is always available
Expand Down
20 changes: 20 additions & 0 deletions cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 19 additions & 3 deletions cmd/thv-operator/pkg/registryapi/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,16 @@ func validateRegistryNames(registries []mcpv1alpha1.MCPRegistryConfig) error {
}

func buildFilePath(registryName string) *FileConfig {
return buildFilePathWithCustomName(registryName, RegistryJSONFileName)
}

func buildFilePathWithCustomName(registryName string, filename string) *FileConfig {
return &FileConfig{
Path: filepath.Join(RegistryJSONFilePath, registryName, RegistryJSONFileName),
Path: filepath.Join(RegistryJSONFilePath, registryName, filename),
}
}

//nolint:gocyclo // Complexity is acceptable for handling multiple source types
func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*RegistryConfig, error) {
if registrySpec.Name == "" {
return nil, fmt.Errorf("registry name is required")
Expand Down Expand Up @@ -330,12 +335,23 @@ func buildRegistryConfig(registrySpec *mcpv1alpha1.MCPRegistryConfig) (*Registry
}
registryConfig.API = apiConfig
}
if registrySpec.PVCRef != nil {
sourceCount++
// PVC sources are mounted at /config/registry/{registryName}/
// File path: /config/registry/{registryName}/{pvcRef.path}
// Multiple registries can share the same PVC by mounting it at different paths
pvcPath := RegistryJSONFileName
if registrySpec.PVCRef.Path != "" {
pvcPath = registrySpec.PVCRef.Path
}
registryConfig.File = buildFilePathWithCustomName(registrySpec.Name, pvcPath)
}

if sourceCount == 0 {
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, or API) must be specified")
return nil, fmt.Errorf("exactly one source type (ConfigMapRef, Git, API, or PVCRef) must be specified")
}
if sourceCount > 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
Expand Down
121 changes: 121 additions & 0 deletions cmd/thv-operator/pkg/registryapi/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading