Skip to content

Commit b92b9e7

Browse files
authored
Add file-based registry provider to thv-registry-api (#1909)
* Add file-based registry provider to thv-registry-api Extends the registry API server to support reading registry data from local files in addition to Kubernetes ConfigMaps. This enables deployment scenarios where ConfigMaps are mounted as volumes rather than accessed via the Kubernetes API. The server now accepts either: - `--configmap` flag: Reads from Kubernetes ConfigMap via API - `--file` flag: Reads from mounted file (for volume-mounted ConfigMaps) * Pass the registry name explicitly as a command line flag * Rename parameters to --from-configmap and --from-file * fix lint
1 parent 59e8f04 commit b92b9e7

File tree

10 files changed

+1003
-30
lines changed

10 files changed

+1003
-30
lines changed

cmd/thv-registry-api/api/v1/routes_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,11 @@ func (*realisticRegistryProvider) GetSource() string {
257257
return "test:realistic-registry-data"
258258
}
259259

260+
// GetRegistryName implements RegistryDataProvider.GetRegistryName
261+
func (*realisticRegistryProvider) GetRegistryName() string {
262+
return "test-registry"
263+
}
264+
260265
func TestHealthRouter(t *testing.T) {
261266
t.Parallel()
262267
ctrl := gomock.NewController(t)
@@ -607,6 +612,11 @@ func (*fileBasedRegistryProvider) GetSource() string {
607612
return "embedded:pkg/registry/data/registry.json"
608613
}
609614

615+
// GetRegistryName implements RegistryDataProvider.GetRegistryName
616+
func (*fileBasedRegistryProvider) GetRegistryName() string {
617+
return "embedded-registry"
618+
}
619+
610620
// TestRoutesWithRealData tests all routes using the embedded registry.json data
611621
// This provides integration-style testing with realistic MCP server configurations
612622
func TestRoutesWithRealData(t *testing.T) {

cmd/thv-registry-api/app/serve.go

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ var serveCmd = &cobra.Command{
2727
Use: "serve",
2828
Short: "Start the registry API server",
2929
Long: `Start the registry API server to serve MCP registry data.
30-
The server reads registry data from ConfigMaps and provides REST endpoints for clients.`,
30+
The server can read registry data from either:
31+
- ConfigMaps using --from-configmap flag (requires Kubernetes API access)
32+
- Local files using --from-file flag (for mounted ConfigMaps)
33+
34+
Both options require --registry-name to specify the registry identifier.
35+
One of --from-configmap or --from-file must be specified.`,
3136
RunE: runServe,
3237
}
3338

@@ -41,15 +46,25 @@ const (
4146

4247
func init() {
4348
serveCmd.Flags().String("address", ":8080", "Address to listen on")
44-
serveCmd.Flags().String("configmap", "", "ConfigMap name containing registry data")
49+
serveCmd.Flags().String("from-configmap", "", "ConfigMap name containing registry data (mutually exclusive with --from-file)")
50+
serveCmd.Flags().String("from-file", "", "File path to registry.json (mutually exclusive with --from-configmap)")
51+
serveCmd.Flags().String("registry-name", "", "Registry name identifier (required)")
4552

4653
err := viper.BindPFlag("address", serveCmd.Flags().Lookup("address"))
4754
if err != nil {
4855
logger.Fatalf("Failed to bind address flag: %v", err)
4956
}
50-
err = viper.BindPFlag("configmap", serveCmd.Flags().Lookup("configmap"))
57+
err = viper.BindPFlag("from-configmap", serveCmd.Flags().Lookup("from-configmap"))
58+
if err != nil {
59+
logger.Fatalf("Failed to bind from-configmap flag: %v", err)
60+
}
61+
err = viper.BindPFlag("from-file", serveCmd.Flags().Lookup("from-file"))
62+
if err != nil {
63+
logger.Fatalf("Failed to bind from-file flag: %v", err)
64+
}
65+
err = viper.BindPFlag("registry-name", serveCmd.Flags().Lookup("registry-name"))
5166
if err != nil {
52-
logger.Fatalf("Failed to bind configmap flag: %v", err)
67+
logger.Fatalf("Failed to bind registry-name flag: %v", err)
5368
}
5469
}
5570

@@ -68,48 +83,95 @@ func getKubernetesConfig() (*rest.Config, error) {
6883
return kubeConfig.ClientConfig()
6984
}
7085

71-
func runServe(_ *cobra.Command, _ []string) error {
72-
ctx := context.Background()
86+
// buildProviderConfig creates provider configuration based on command-line flags
87+
func buildProviderConfig() (*service.RegistryProviderConfig, error) {
88+
configMapName := viper.GetString("from-configmap")
89+
filePath := viper.GetString("from-file")
90+
registryName := viper.GetString("registry-name")
7391

74-
// Get configuration
75-
address := viper.GetString("address")
76-
configMapName := viper.GetString("configmap")
92+
// Validate mutual exclusivity
93+
if configMapName != "" && filePath != "" {
94+
return nil, fmt.Errorf("--from-configmap and --from-file flags are mutually exclusive")
95+
}
96+
97+
// Require one of the flags
98+
if configMapName == "" && filePath == "" {
99+
return nil, fmt.Errorf("either --from-configmap or --from-file flag is required")
100+
}
77101

78-
if configMapName == "" {
79-
return fmt.Errorf("configmap flag is required")
102+
// Require registry name
103+
if registryName == "" {
104+
return nil, fmt.Errorf("--registry-name flag is required")
80105
}
81106

82-
namespace := thvk8scli.GetCurrentNamespace()
107+
if configMapName != "" {
108+
config, err := getKubernetesConfig()
109+
if err != nil {
110+
return nil, fmt.Errorf("failed to create kubernetes config: %w", err)
111+
}
112+
113+
clientset, err := kubernetes.NewForConfig(config)
114+
if err != nil {
115+
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
116+
}
117+
118+
return &service.RegistryProviderConfig{
119+
Type: service.RegistryProviderTypeConfigMap,
120+
ConfigMap: &service.ConfigMapProviderConfig{
121+
Name: configMapName,
122+
Namespace: thvk8scli.GetCurrentNamespace(),
123+
Clientset: clientset,
124+
RegistryName: registryName,
125+
},
126+
}, nil
127+
}
128+
129+
return &service.RegistryProviderConfig{
130+
Type: service.RegistryProviderTypeFile,
131+
File: &service.FileProviderConfig{
132+
FilePath: filePath,
133+
RegistryName: registryName,
134+
},
135+
}, nil
136+
}
137+
138+
func runServe(_ *cobra.Command, _ []string) error {
139+
ctx := context.Background()
140+
address := viper.GetString("address")
83141

84142
logger.Infof("Starting registry API server on %s", address)
85-
logger.Infof("ConfigMap: %s, Namespace: %s", configMapName, namespace)
86143

87-
// Create Kubernetes client and providers
88-
var registryProvider service.RegistryDataProvider
89-
var deploymentProvider service.DeploymentProvider
144+
providerConfig, err := buildProviderConfig()
145+
if err != nil {
146+
return fmt.Errorf("failed to build provider configuration: %w", err)
147+
}
90148

91-
// Get Kubernetes config
92-
config, err := getKubernetesConfig()
149+
if err := providerConfig.Validate(); err != nil {
150+
return fmt.Errorf("invalid provider configuration: %w", err)
151+
}
152+
153+
factory := service.NewRegistryProviderFactory()
154+
registryProvider, err := factory.CreateProvider(providerConfig)
93155
if err != nil {
94-
return fmt.Errorf("failed to create kubernetes config: %w", err)
156+
return fmt.Errorf("failed to create registry provider: %w", err)
95157
}
96158

97-
// Create Kubernetes client
98-
clientset, err := kubernetes.NewForConfig(config)
159+
logger.Infof("Created registry data provider: %s", registryProvider.GetSource())
160+
161+
var deploymentProvider service.DeploymentProvider
162+
config, err := getKubernetesConfig()
99163
if err != nil {
100-
return fmt.Errorf("failed to create kubernetes client: %w", err)
164+
return fmt.Errorf("failed to create kubernetes config for deployment provider: %w", err)
101165
}
102166

103-
// Create the Kubernetes-based registry data provider
104-
registryProvider = service.NewK8sRegistryDataProvider(clientset, configMapName, namespace)
105-
logger.Infof("Created Kubernetes registry data provider for ConfigMap %s/%s", namespace, configMapName)
167+
// Use registry name from provider
168+
registryName := registryProvider.GetRegistryName()
106169

107-
// Create the Kubernetes-based deployment provider
108-
deploymentProvider, err = service.NewK8sDeploymentProvider(config, configMapName)
170+
deploymentProvider, err = service.NewK8sDeploymentProvider(config, registryName)
109171
if err != nil {
110172
return fmt.Errorf("failed to create kubernetes deployment provider: %w", err)
111173
}
112-
logger.Infof("Created Kubernetes deployment provider for registry: %s", configMapName)
174+
logger.Infof("Created Kubernetes deployment provider for registry: %s", registryName)
113175

114176
// Create the registry service
115177
svc, err := service.NewService(ctx, registryProvider, deploymentProvider)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Package service provides the business logic for the MCP registry API
2+
package service
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
11+
"github.com/stacklok/toolhive/pkg/registry"
12+
)
13+
14+
// FileRegistryDataProvider implements RegistryDataProvider using local file system.
15+
// This implementation reads registry data from a mounted file instead of calling the Kubernetes API.
16+
// It is designed to work with ConfigMaps mounted as volumes in Kubernetes deployments.
17+
type FileRegistryDataProvider struct {
18+
filePath string
19+
registryName string
20+
}
21+
22+
// NewFileRegistryDataProvider creates a new file-based registry data provider.
23+
// The filePath parameter should point to the registry.json file, typically mounted from a ConfigMap.
24+
// The registryName parameter specifies the registry identifier for business logic purposes.
25+
func NewFileRegistryDataProvider(filePath, registryName string) *FileRegistryDataProvider {
26+
return &FileRegistryDataProvider{
27+
filePath: filePath,
28+
registryName: registryName,
29+
}
30+
}
31+
32+
// GetRegistryData implements RegistryDataProvider.GetRegistryData.
33+
// It reads the registry.json file from the local filesystem and parses it into a Registry struct.
34+
func (p *FileRegistryDataProvider) GetRegistryData(_ context.Context) (*registry.Registry, error) {
35+
if p.filePath == "" {
36+
return nil, fmt.Errorf("file path not configured")
37+
}
38+
39+
// Check if the file exists and is readable
40+
if _, err := os.Stat(p.filePath); err != nil {
41+
if os.IsNotExist(err) {
42+
return nil, fmt.Errorf("registry file not found at %s: %w", p.filePath, err)
43+
}
44+
return nil, fmt.Errorf("cannot access registry file at %s: %w", p.filePath, err)
45+
}
46+
47+
// Read the file contents
48+
data, err := os.ReadFile(p.filePath)
49+
if err != nil {
50+
return nil, fmt.Errorf("failed to read registry file at %s: %w", p.filePath, err)
51+
}
52+
53+
// Parse the JSON data
54+
var reg registry.Registry
55+
if err := json.Unmarshal(data, &reg); err != nil {
56+
return nil, fmt.Errorf("failed to parse registry data from file %s: %w", p.filePath, err)
57+
}
58+
59+
return &reg, nil
60+
}
61+
62+
// GetSource implements RegistryDataProvider.GetSource.
63+
// It returns a descriptive string indicating the file source.
64+
func (p *FileRegistryDataProvider) GetSource() string {
65+
if p.filePath == "" {
66+
return "file:<not-configured>"
67+
}
68+
69+
// Clean the path for consistent display
70+
cleanPath := filepath.Clean(p.filePath)
71+
return fmt.Sprintf("file:%s", cleanPath)
72+
}
73+
74+
// GetRegistryName implements RegistryDataProvider.GetRegistryName.
75+
// It returns the injected registry name identifier.
76+
func (p *FileRegistryDataProvider) GetRegistryName() string {
77+
return p.registryName
78+
}

0 commit comments

Comments
 (0)