diff --git a/cmd/thv-operator/README.md b/cmd/thv-operator/README.md index a6bb2a7418..f5709bf54c 100644 --- a/cmd/thv-operator/README.md +++ b/cmd/thv-operator/README.md @@ -256,6 +256,10 @@ The ConfigMap should contain a JSON permission profile. ### Creating an MCP Registry +The MCPRegistry CRD uses a `configYAML` field that contains the complete +registry server configuration. The operator passes this content through +to the registry server verbatim. + First, create a ConfigMap containing ToolHive registry data. The ConfigMap must be user-defined and is not managed by the operator: ```bash @@ -266,7 +270,7 @@ kubectl create configmap my-registry-data --from-file registry.json=pkg/registry kubectl create configmap my-registry-data --from-file registry.json=/path/to/your/registry.json -n toolhive-system ``` -Then create the MCPRegistry resource that references the ConfigMap: +Then create the MCPRegistry resource with `configYAML` and mount the ConfigMap: ```yaml apiVersion: toolhive.stacklok.dev/v1alpha1 @@ -276,16 +280,38 @@ metadata: namespace: toolhive-system spec: displayName: 'My MCP Registry' - source: - type: configmap - configMapRef: - name: my-registry-data # References the user-created ConfigMap - key: registry.json # Key in ConfigMap (required) - syncPolicy: - interval: '1h' + configYAML: | + sources: + - name: my-source + format: toolhive + file: + path: /config/registry/my-source/registry.json + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["my-source"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: my-source-data + configMap: + name: my-registry-data + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: my-source-data + mountPath: /config/registry/my-source + readOnly: true ``` -For complete MCPRegistry examples and documentation, see [REGISTRY.md](REGISTRY.md). +For complete MCPRegistry examples and documentation, see [REGISTRY.md](REGISTRY.md) and the `examples/operator/mcp-registries/` directory. ## Examples diff --git a/cmd/thv-operator/REGISTRY.md b/cmd/thv-operator/REGISTRY.md index a1ef777dd1..766e77d3fa 100644 --- a/cmd/thv-operator/REGISTRY.md +++ b/cmd/thv-operator/REGISTRY.md @@ -4,35 +4,13 @@ MCPRegistry is a Kubernetes Custom Resource that manages MCP (Model Context Protocol) server registries. It provides centralized server discovery, automated synchronization, and image validation for MCP servers in your cluster. +The MCPRegistry CRD uses a **pass-through configuration model**: you provide the complete registry server `config.yaml` content in `spec.configYAML`, and the operator passes it through verbatim as a ConfigMap -- no parsing or transformation. This gives you full control over the registry server configuration while the operator handles only the Kubernetes deployment plumbing. + ## Quick Start -Create a basic registry from a ConfigMap: +Create a minimal registry with a Kubernetes source (watches MCPServer resources in the namespace): ```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: my-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-01-14T00:00:00Z", - "servers": { - "github": { - "description": "GitHub API integration", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": ["create_issue", "search_repositories"], - "image": "ghcr.io/github/github-mcp-server:latest", - "tags": ["github", "api", "production"] - } - } - } ---- apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPRegistry metadata: @@ -40,15 +18,21 @@ metadata: namespace: toolhive-system spec: displayName: "My MCP Registry" - sources: - - name: configmap-source - configMapRef: - name: my-registry-data - key: registry.json - registries: - - name: default - sources: - - configmap-source + configYAML: | + sources: + - name: k8s + format: upstream + kubernetes: {} + registries: + - name: default + sources: ["k8s"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` Apply with: @@ -56,26 +40,68 @@ Apply with: kubectl apply -f my-registry.yaml ``` +### CRD Fields + +The MCPRegistry spec has four fields for configuring the registry server: + +| Field | Required | Purpose | +|---|---|---| +| `configYAML` | Yes | Complete registry server `config.yaml` content (passed through verbatim) | +| `volumes` | No | Standard Kubernetes volumes to add to the pod (for Secrets, ConfigMaps, etc.) | +| `volumeMounts` | No | Standard Kubernetes volume mounts for the registry-api container | +| `pgpassSecretRef` | No | Reference to a Secret containing a pgpass file (operator handles chmod 0600 plumbing) | + +Additional fields (`displayName`, `enforceServers`, `podTemplateSpec`) are documented in later sections. + +### Config YAML Structure + +The `configYAML` field contains a complete registry server configuration. Top-level sections: + +| Section | Required | Purpose | +|---|---|---| +| `sources` | Yes | Data source definitions (file, git, api, kubernetes) | +| `registries` | Yes | Registry views that aggregate sources | +| `database` | Yes | PostgreSQL connection settings | +| `auth` | Yes | Authentication mode (`anonymous` or `oauth`) | +| `telemetry` | No | OpenTelemetry configuration | + ## Sync Operations ### Automatic Sync -Configure automatic synchronization with interval-based policies per registry: +Configure automatic synchronization with interval-based policies per source inside `configYAML`: ```yaml spec: - sources: - - name: default - format: toolhive - configMapRef: + configYAML: | + sources: + - name: default + format: toolhive + file: + path: /config/registry/default/registry.json + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["default"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: registry-data + configMap: name: registry-data - key: registry.json - syncPolicy: - interval: "1h" # Sync every hour - registries: - - name: default - sources: - - default + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: registry-data + mountPath: /config/registry/default + readOnly: true ``` Supported intervals: @@ -112,41 +138,97 @@ Status phases: ## Data Sources +All data sources are configured inside `configYAML`. When a source references data on the filesystem (ConfigMap files, git auth tokens), you must also define `spec.volumes` and `spec.volumeMounts` to wire those files into the registry-api container. The file paths in `configYAML` must match the mount paths in `volumeMounts`. + ### ConfigMap Source -Store registry data in Kubernetes ConfigMaps: +Store registry data in Kubernetes ConfigMaps. In `configYAML`, use the `file:` source type with a `path` that matches the `volumeMount` mount path. Define the ConfigMap volume and mount explicitly in the MCPRegistry spec. ```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: registry-data + namespace: toolhive-system +data: + registry.json: | + { + "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", + "version": "1.0.0", + "last_updated": "2025-01-14T00:00:00Z", + "servers": { + "github": { + "description": "GitHub API integration", + "tier": "Official", + "status": "Active", + "transport": "stdio", + "tools": ["create_issue", "search_repositories"], + "image": "ghcr.io/github/github-mcp-server:latest", + "tags": ["github", "api", "production"] + } + } + } +--- +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPRegistry +metadata: + name: my-registry + namespace: toolhive-system spec: - sources: - - name: default - format: toolhive # or "upstream" - configMapRef: + displayName: "My MCP Registry" + configYAML: | + sources: + - name: configmap-source + format: toolhive + file: + path: /config/registry/default/registry.json + registries: + - name: default + sources: ["configmap-source"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: registry-data + configMap: name: registry-data - key: registry.json # required - registries: - - name: default - sources: - - default + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: registry-data + mountPath: /config/registry/default + readOnly: true ``` ### Git Source -Synchronize from Git repositories: +Synchronize from Git repositories. The git configuration goes inside `configYAML`: ```yaml spec: - sources: - - name: default - format: toolhive - git: - repository: "https://github.com/org/mcp-registry" - branch: "main" - path: "registry.json" # optional, defaults to "registry.json" - registries: - - name: default - sources: - - default + configYAML: | + sources: + - name: default + format: toolhive + git: + repository: https://github.com/org/mcp-registry + branch: main + path: registry.json + registries: + - name: default + sources: ["default"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` Supported repository URL formats: @@ -158,7 +240,7 @@ Supported repository URL formats: #### Private Repository Authentication -For private Git repositories, you can configure authentication using HTTP Basic Auth: +For private Git repositories, configure authentication using `passwordFile` in `configYAML` and mount the Secret containing the token via `volumes`/`volumeMounts`: ```yaml # First, create a Secret containing your Git credentials @@ -169,7 +251,7 @@ metadata: namespace: toolhive-system type: Opaque stringData: - password: "ghp_your_github_token_here" # GitHub PAT, GitLab token, etc. + token: "ghp_your_github_token_here" --- apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPRegistry @@ -178,24 +260,43 @@ metadata: namespace: toolhive-system spec: displayName: "Private MCP Registry" - sources: - - name: default - format: toolhive - git: - repository: "https://github.com/org/private-mcp-registry" - branch: "main" - path: "registry.json" - auth: - username: "git" # Use "git" for GitHub PATs - passwordSecretRef: - name: git-credentials - key: password - syncPolicy: - interval: "1h" - registries: - - name: default - sources: - - default + configYAML: | + sources: + - name: default + format: toolhive + git: + repository: https://github.com/org/private-mcp-registry + branch: main + path: registry.json + auth: + username: git + # File path must match the volumeMount below + passwordFile: /secrets/git-credentials/token + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["default"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + # Volume to project the git credentials secret into the container filesystem + volumes: + - name: git-auth-credentials + secret: + secretName: git-credentials + items: + - key: token + path: token + # Mount the secret at the path referenced by passwordFile in configYAML + volumeMounts: + - name: git-auth-credentials + mountPath: /secrets/git-credentials + readOnly: true ``` **Authentication notes:** @@ -203,24 +304,33 @@ spec: - For **GitLab tokens**: Use `username: "oauth2"` with the token as the password - For **Bitbucket app passwords**: Use your Bitbucket username with the app password - The Secret must exist in the same namespace as the MCPRegistry -- The password file is securely mounted at `/secrets/{secretName}/{key}` in the registry-api pod, where `{key}` is the value specified in `passwordSecretRef.key` +- The `passwordFile` path in `configYAML` must match the `mountPath` + file name in `volumeMounts`/`volumes` ### API Source -Synchronize from HTTP/HTTPS API endpoints compatible with -[Model Context Protocol Registry API](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/generic-registry-api.md): +Synchronize from HTTP/HTTPS API endpoints compatible with +[Model Context Protocol Registry API](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/generic-registry-api.md). + +API sources require no volumes or volume mounts -- the registry server handles the network call internally. API sources must use `format: upstream`. ```yaml spec: - sources: - - name: default - format: toolhive - api: - endpoint: "https://registry.example.com" - registries: - - name: default - sources: - - default + configYAML: | + sources: + - name: default + format: upstream + api: + endpoint: https://registry.example.com + registries: + - name: default + sources: ["default"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` The API source automatically detects the registry format by probing the endpoint: @@ -242,33 +352,47 @@ Example configurations: **Internal ToolHive Registry API:** ```yaml spec: - sources: - - name: internal-api - format: toolhive - api: - endpoint: "http://my-registry-api.default.svc.cluster.local:8080" - syncPolicy: - interval: "30m" - registries: - - name: default - sources: - - internal-api + configYAML: | + sources: + - name: internal-api + format: upstream + api: + endpoint: http://my-registry-api.default.svc.cluster.local:8080 + syncPolicy: + interval: 30m + registries: + - name: default + sources: ["internal-api"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` **External Registry API:** ```yaml spec: - sources: - - name: upstream - format: toolhive - api: - endpoint: "https://registry.modelcontextprotocol.io/" - syncPolicy: - interval: "1h" - registries: - - name: default - sources: - - upstream + configYAML: | + sources: + - name: upstream + format: upstream + api: + endpoint: https://registry.modelcontextprotocol.io/ + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["upstream"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` **Notes:** @@ -288,40 +412,207 @@ spec: - Standard MCP registry format - Compatible with community registries - Automatically converted to ToolHive format +- Required for API sources (`format: upstream`) - **Note**: Not supported until the upstream schema is more stable ## Filtering -Each registry configuration can define its own filtering rules: +Each source inside `configYAML` can define its own filtering rules: ```yaml spec: - sources: - - name: production - format: toolhive - configMapRef: + configYAML: | + sources: + - name: production + format: toolhive + file: + path: /config/registry/production/registry.json + filter: + names: + include: + - "prod-*" + exclude: + - "*-legacy" + tags: + include: + - "production" + exclude: + - "experimental" + - "deprecated" + registries: + - name: default + sources: ["production"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: registry-data-production + configMap: name: registry-data - key: registry.json - filter: - names: - include: - - "prod-*" - exclude: - - "*-legacy" - tags: - include: - - "production" - exclude: - - "experimental" - - "deprecated" - registries: - - name: default - sources: - - production + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: registry-data-production + mountPath: /config/registry/production + readOnly: true ``` Filtering is applied per-source, allowing different filtering rules for different data sources in the same MCPRegistry. +## Database Configuration + +### PostgreSQL with pgpass + +Configure database settings inside `configYAML` and provide credentials via `spec.pgpassSecretRef`. The operator handles the Kubernetes plumbing for pgpass automatically: it creates an init container that copies the pgpass file from the Secret to an emptyDir volume and runs `chmod 0600` (required by libpq), then mounts the file at `/home/appuser/.pgpass` and sets the `PGPASSFILE` environment variable. + +This is necessary because Kubernetes secret volumes mount files as root-owned, and the registry container runs as non-root (UID 65532). A root-owned 0600 file is unreadable by UID 65532, and using `fsGroup` changes permissions to 0640 which libpq also rejects. + +```yaml +# Secret containing the pgpass file +# Format: hostname:port:database:username:password (one entry per line) +# See https://www.postgresql.org/docs/current/libpq-pgpass.html +apiVersion: v1 +kind: Secret +metadata: + name: my-registry-pgpass + namespace: toolhive-system +type: Opaque +stringData: + .pgpass: | + registry-db-rw:5432:registry:db_app:myapppassword + registry-db-rw:5432:registry:db_migrator:mymigrationpassword +--- +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPRegistry +metadata: + name: my-registry + namespace: toolhive-system +spec: + displayName: "Registry with Database Auth" + configYAML: | + sources: + - name: production + format: toolhive + file: + path: /config/registry/production/registry.json + registries: + - name: default + sources: ["production"] + database: + host: registry-db-rw + port: 5432 + user: db_app + migrationUser: db_migrator + database: registry + sslMode: require + maxOpenConns: 20 + auth: + mode: anonymous + pgpassSecretRef: + name: my-registry-pgpass + key: .pgpass + volumes: + - name: registry-data-production + configMap: + name: prod-registry + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: registry-data-production + mountPath: /config/registry/production + readOnly: true +``` + +## Authentication + +### OAuth Configuration + +Configure OAuth authentication inside `configYAML`. OAuth secrets (client secrets) and CA certificates are mounted via `volumes`/`volumeMounts`, and their file paths must match the `clientSecretFile` and `caCertPath` values in `configYAML`. + +Auth defaults to `oauth` mode. Use `mode: anonymous` for development and testing. + +```yaml +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPRegistry +metadata: + name: secure-registry + namespace: toolhive-system +spec: + displayName: "Secure Registry with OAuth" + configYAML: | + sources: + - name: production + format: toolhive + file: + path: /config/registry/production/registry.json + registries: + - name: default + sources: ["production"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: oauth + oauth: + resourceUrl: https://registry.example.com + realm: mcp-registry + scopesSupported: + - mcp-registry:read + - mcp-registry:write + providers: + - name: keycloak + issuerUrl: https://keycloak.example.com/realms/mcp + audience: mcp-registry + clientId: mcp-registry + # File path must match the volumeMount for the OAuth client secret + clientSecretFile: /secrets/oauth-client-secret/secret + # File path must match the volumeMount for the CA certificate + caCertPath: /config/certs/keycloak-ca/ca.crt + volumes: + # Registry data from a ConfigMap + - name: registry-data-production + configMap: + name: prod-registry + items: + - key: registry.json + path: registry.json + # OAuth client secret from a Kubernetes Secret + - name: oauth-client-secret + secret: + secretName: oauth-client-secret + items: + - key: secret + path: secret + # CA certificate for the OAuth provider from a ConfigMap + - name: keycloak-ca + configMap: + name: keycloak-ca + items: + - key: ca.crt + path: ca.crt + volumeMounts: + # Mount registry data at the path referenced in configYAML sources + - name: registry-data-production + mountPath: /config/registry/production + readOnly: true + # Mount OAuth client secret at the path referenced by clientSecretFile + - name: oauth-client-secret + mountPath: /secrets/oauth-client-secret + readOnly: true + # Mount CA certificate at the path referenced by caCertPath + - name: keycloak-ca + mountPath: /config/certs/keycloak-ca + readOnly: true +``` + ## Image Validation ### Registry-Based Enforcement @@ -480,7 +771,7 @@ status: ### Secret Management -Git authentication credentials are stored securely in Kubernetes Secrets: +Do not inline credentials (passwords, tokens, client secrets) in `configYAML` -- it is stored in a ConfigMap, not a Secret. Instead, mount credentials as Kubernetes Secrets via `volumes`/`volumeMounts` and reference them by file path in `configYAML`. ```yaml apiVersion: v1 @@ -490,15 +781,16 @@ metadata: namespace: toolhive-system type: Opaque stringData: - password: "ghp_your_token_here" + token: "ghp_your_token_here" ``` -**Best practices for Git credentials:** +**Best practices for credentials:** 1. **Use tokens, not passwords**: Prefer GitHub PATs, GitLab tokens, or app passwords over account passwords 2. **Scope tokens minimally**: Grant only `repo:read` or equivalent read-only permissions 3. **Rotate regularly**: Set up token rotation policies 4. **Use separate tokens per registry**: Don't share tokens across registries 5. **Consider RBAC**: Limit which service accounts can read the credentials Secret +6. **Use pgpassSecretRef for database credentials**: The operator handles the chmod 0600 plumbing automatically ### Image Security @@ -516,8 +808,10 @@ stringData: kubectl get mcpregistry my-registry -o jsonpath='{.status.syncStatus.message}' # Common causes: -# - Invalid ConfigMap/Git source -# - Network connectivity issues +# - Invalid configYAML content +# - Volume/volumeMount path mismatch with configYAML file paths +# - ConfigMap or Secret referenced in volumes does not exist +# - Network connectivity issues for git/api sources # - Malformed registry data ``` @@ -532,7 +826,7 @@ kubectl get deployment my-registry-api # Common causes: # - Resource constraints # - Image pull failures -# - Configuration errors +# - Configuration errors in configYAML ``` **Image Validation Errors**: @@ -583,21 +877,62 @@ apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPRegistry metadata: name: production-registry + namespace: toolhive-system spec: displayName: "Production MCP Servers" - sources: - - name: prod-source - format: toolhive - configMapRef: - name: prod-registry-data - key: registry.json - syncPolicy: - interval: "1h" - registries: - - name: default - sources: - - prod-source + configYAML: | + sources: + - name: prod-source + format: toolhive + file: + path: /config/registry/production/registry.json + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["prod-source"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: oauth + oauth: + resourceUrl: https://registry.example.com + realm: mcp-registry + scopesSupported: + - mcp-registry:read + providers: + - name: keycloak + issuerUrl: https://keycloak.example.com/realms/mcp + audience: mcp-registry + clientId: mcp-registry + clientSecretFile: /secrets/oauth-client-secret/secret enforceServers: true + pgpassSecretRef: + name: production-pgpass + key: .pgpass + volumes: + - name: registry-data-production + configMap: + name: prod-registry-data + items: + - key: registry.json + path: registry.json + - name: oauth-client-secret + secret: + secretName: oauth-client-secret + items: + - key: secret + path: secret + volumeMounts: + - name: registry-data-production + mountPath: /config/registry/production + readOnly: true + - name: oauth-client-secret + mountPath: /secrets/oauth-client-secret + readOnly: true ``` ### Development Registry @@ -606,20 +941,27 @@ apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPRegistry metadata: name: dev-registry + namespace: toolhive-system spec: displayName: "Development MCP Servers" - sources: - - name: dev-source - format: toolhive - git: - repository: "https://github.com/org/dev-mcp-registry" - branch: "develop" - path: "registry.json" - # No sync policy = manual sync only - registries: - - name: default - sources: - - dev-source + configYAML: | + sources: + - name: dev-source + format: toolhive + git: + repository: https://github.com/org/dev-mcp-registry + branch: develop + path: registry.json + registries: + - name: default + sources: ["dev-source"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` ### Private Git Repository Registry @@ -640,72 +982,105 @@ metadata: namespace: toolhive-system spec: displayName: "Private Organization Registry" - sources: - - name: private-source - format: toolhive - git: - repository: "https://github.com/myorg/private-mcp-servers" - branch: "main" - path: "registry.json" - auth: - username: "git" - passwordSecretRef: - name: private-repo-token - key: token - syncPolicy: - interval: "30m" - registries: - - name: default - sources: - - private-source + configYAML: | + sources: + - name: private-source + format: toolhive + git: + repository: https://github.com/myorg/private-mcp-servers + branch: main + path: registry.json + auth: + username: git + passwordFile: /secrets/private-repo-token/token + syncPolicy: + interval: 30m + registries: + - name: default + sources: ["private-source"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: git-auth-credentials + secret: + secretName: private-repo-token + items: + - key: token + path: token + volumeMounts: + - name: git-auth-credentials + mountPath: /secrets/private-repo-token + readOnly: true ``` ### Multiple Sources -You can configure multiple data sources in a single MCPRegistry and aggregate them into registry views: +You can configure multiple data sources in a single MCPRegistry and aggregate them into registry views. All source and registry configuration lives inside `configYAML`: ```yaml apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPRegistry metadata: name: multi-source-registry + namespace: toolhive-system spec: displayName: "Multi-Source Registry" - sources: - - name: production - format: toolhive - git: - repository: "https://github.com/org/prod-registry" - branch: "main" - path: "registry.json" - syncPolicy: - interval: "1h" - filter: - tags: - include: - - "production" - - name: development - format: toolhive - configMapRef: + configYAML: | + sources: + - name: production + format: toolhive + git: + repository: https://github.com/org/prod-registry + branch: main + path: registry.json + syncPolicy: + interval: 1h + filter: + tags: + include: ["production"] + - name: development + format: toolhive + file: + path: /config/registry/development/registry.json + filter: + tags: + include: ["development"] + registries: + - name: default + sources: + - production + - development + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: dev-registry-data + configMap: name: dev-registry-data - key: registry.json - filter: - tags: - include: - - "development" - registries: - - name: default - sources: - - production - - development + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: dev-registry-data + mountPath: /config/registry/development + readOnly: true ``` -Each source must have a unique `name` within the MCPRegistry. Registry views reference sources by name. +Each source must have a unique `name` within the `configYAML`. Registry views reference sources by name. ## See Also - [MCPServer Documentation](README.md#usage) - [Operator Installation](../../docs/kind/deploying-toolhive-operator.md) - [Registry Examples](../../examples/operator/mcp-registries/) -- [Private Git Registry Example](../../examples/operator/mcp-registries/mcpregistry-git-private.yaml) +- [Private Git Registry Example](../../examples/operator/mcp-registries/mcpregistry-configyaml-git-auth.yaml) - [Registry Schema](../../pkg/registry/data/toolhive-legacy-registry.schema.json) diff --git a/cmd/thv-operator/api/v1alpha1/mcpregistry_types.go b/cmd/thv-operator/api/v1alpha1/mcpregistry_types.go index 85d70ad439..72c4d010b8 100644 --- a/cmd/thv-operator/api/v1alpha1/mcpregistry_types.go +++ b/cmd/thv-operator/api/v1alpha1/mcpregistry_types.go @@ -13,37 +13,27 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) -// Registry formats -const ( - // RegistryFormatToolHive is the native ToolHive registry format - RegistryFormatToolHive = "toolhive" - // RegistryFormatUpstream is the standard MCP registry format - RegistryFormatUpstream = "upstream" -) - // MCPRegistrySpec defines the desired state of MCPRegistry type MCPRegistrySpec struct { - // ============================================================ - // New decoupled config fields - // ============================================================ - // ConfigYAML is the complete registry server config.yaml content. // The operator creates a ConfigMap from this string and mounts it // at /config/config.yaml in the registry-api container. - // The operator does NOT parse, validate, or transform this content. + // The operator does NOT parse, validate, or transform this content — + // configuration validation is the registry server's responsibility. // - // Mutually exclusive with the legacy typed fields (Sources, Registries, - // DatabaseConfig, AuthConfig, TelemetryConfig). When set, the operator - // uses the decoupled code path — volumes and mounts must be provided - // via the Volumes and VolumeMounts fields below. + // Security note: this content is stored in a ConfigMap, not a Secret. + // Do not inline credentials (passwords, tokens, client secrets) in this + // field. Instead, reference credentials via file paths and mount the + // actual secrets using the Volumes and VolumeMounts fields. For database + // passwords, use PGPassSecretRef. // - // +optional - ConfigYAML string `json:"configYAML,omitempty"` + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + ConfigYAML string `json:"configYAML"` // Volumes defines additional volumes to add to the registry API pod. // Each entry is a standard Kubernetes Volume object (JSON/YAML). // The operator appends them to the pod spec alongside its own config volume. - // Only used when configYAML is set. // // Use these to mount: // - Secrets (git auth tokens, OAuth client secrets, CA certs) @@ -59,7 +49,6 @@ type MCPRegistrySpec struct { // VolumeMounts defines additional volume mounts for the registry-api container. // Each entry is a standard Kubernetes VolumeMount object (JSON/YAML). // The operator appends them to the container's volume mounts alongside the config mount. - // Only used when configYAML is set. // // Mount paths must match the file paths referenced in configYAML. // For example, if configYAML references passwordFile: /secrets/git-creds/token, @@ -71,7 +60,6 @@ type MCPRegistrySpec struct { VolumeMounts []apiextensionsv1.JSON `json:"volumeMounts,omitempty"` // PGPassSecretRef references a Secret containing a pre-created pgpass file. - // Only used when configYAML is set. Mutually exclusive with DatabaseConfig. // // Why this is a dedicated field instead of a regular volume/volumeMount: // PostgreSQL's libpq rejects pgpass files that aren't mode 0600. Kubernetes @@ -80,7 +68,7 @@ type MCPRegistrySpec struct { // UID 65532, and using fsGroup changes permissions to 0640 which libpq also // rejects. The only solution is an init container that copies the file to an // emptyDir as the app user and runs chmod 0600. This cannot be expressed - // through volumes/volumeMounts alone — it requires an init container, two + // through volumes/volumeMounts alone -- it requires an init container, two // extra volumes (secret + emptyDir), a subPath mount, and an environment // variable, all wired together correctly. // @@ -108,12 +96,7 @@ type MCPRegistrySpec struct { // +optional PGPassSecretRef *corev1.SecretKeySelector `json:"pgpassSecretRef,omitempty"` - // ============================================================ - // Shared fields — used by both config paths - // ============================================================ - // DisplayName is a human-readable name for the registry. - // Works with both the new configYAML path and the legacy typed path. // +optional DisplayName string `json:"displayName,omitempty"` @@ -131,667 +114,10 @@ type MCPRegistrySpec struct { // Note that to modify the specific container the registry API server runs in, you must specify // the `registry-api` container name in the PodTemplateSpec. // This field accepts a PodTemplateSpec object as JSON/YAML. - // Works with both the new configYAML path and the legacy typed path. // +optional // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Type=object PodTemplateSpec *runtime.RawExtension `json:"podTemplateSpec,omitempty"` - - // ============================================================ - // Deprecated legacy fields - // Deprecated: Use configYAML, volumes, volumeMounts, and - // pgpassSecretRef instead. These fields will be removed in a - // future release. - // ============================================================ - - // Sources defines the data source configurations for the registry. - // Each source defines where registry data comes from (Git, API, ConfigMap, URL, Managed, or Kubernetes). - // Deprecated: Use configYAML with volumes/volumeMounts instead. - // +optional - // +kubebuilder:validation:MaxItems=20 - // +listType=map - // +listMapKey=name - Sources []MCPRegistrySourceConfig `json:"sources,omitempty"` - - // Registries defines lightweight registry views that aggregate one or more sources. - // Each registry references sources by name and can optionally gate access via claims. - // Deprecated: Use configYAML with volumes/volumeMounts instead. - // +optional - // +kubebuilder:validation:MaxItems=20 - // +listType=map - // +listMapKey=name - Registries []MCPRegistryViewConfig `json:"registries,omitempty"` - - // DatabaseConfig defines the PostgreSQL database configuration for the registry API server. - // If not specified, defaults will be used: - // - Host: "postgres" - // - Port: 5432 - // - User: "db_app" - // - MigrationUser: "db_migrator" - // - Database: "registry" - // - SSLMode: "prefer" - // - MaxOpenConns: 10 - // - MaxIdleConns: 2 - // - ConnMaxLifetime: "30m" - // - // Deprecated: Put database config in configYAML and use pgpassSecretRef. - // +optional - DatabaseConfig *MCPRegistryDatabaseConfig `json:"databaseConfig,omitempty"` - - // AuthConfig defines the authentication configuration for the registry API server. - // If not specified, defaults to anonymous authentication. - // Deprecated: Put auth config in configYAML instead. - // +optional - AuthConfig *MCPRegistryAuthConfig `json:"authConfig,omitempty"` - - // TelemetryConfig defines OpenTelemetry configuration for the registry API server. - // When enabled, the server exports traces and metrics via OTLP. - // Deprecated: Put telemetry config in configYAML instead. - // +optional - TelemetryConfig *MCPRegistryTelemetryConfig `json:"telemetryConfig,omitempty"` -} - -// MCPRegistrySourceConfig defines a data source configuration for the registry. -// Exactly one source type must be specified (ConfigMapRef, Git, API, URL, Managed, or Kubernetes). -// -// +kubebuilder:validation:XValidation:rule="(has(self.configMapRef) ? 1 : 0) + (has(self.git) ? 1 : 0) + (has(self.api) ? 1 : 0) + (has(self.url) ? 1 : 0) + (has(self.managed) ? 1 : 0) + (has(self.kubernetes) ? 1 : 0) == 1",message="exactly one source type must be specified" -// -//nolint:lll // CEL validation rules exceed line length limit -type MCPRegistrySourceConfig struct { - // Name is a unique identifier for this source within the MCPRegistry - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` - - // Format is the data format (toolhive, upstream) - // +kubebuilder:validation:Enum=toolhive;upstream - // +kubebuilder:default=toolhive - Format string `json:"format,omitempty"` - - // Claims are key-value pairs attached to this source for authorization purposes. - // All entries from this source inherit these claims. Values must be string or []string. - // +optional - // +kubebuilder:pruning:PreserveUnknownFields - // +kubebuilder:validation:Type=object - Claims *apiextensionsv1.JSON `json:"claims,omitempty"` - - // ConfigMapRef defines the ConfigMap source configuration - // Mutually exclusive with Git, API, URL, Managed, and Kubernetes - // +optional - ConfigMapRef *corev1.ConfigMapKeySelector `json:"configMapRef,omitempty"` - - // Git defines the Git repository source configuration - // Mutually exclusive with ConfigMapRef, API, URL, Managed, and Kubernetes - // +optional - Git *GitSource `json:"git,omitempty"` - - // API defines the API source configuration - // Mutually exclusive with ConfigMapRef, Git, URL, Managed, and Kubernetes - // +optional - API *APISource `json:"api,omitempty"` - - // URL defines a URL-hosted file source configuration. - // The registry server fetches the registry data from the specified HTTP/HTTPS URL. - // Mutually exclusive with ConfigMapRef, Git, API, Managed, and Kubernetes - // +optional - URL *URLSource `json:"url,omitempty"` - - // Managed defines a managed source that is directly manipulated via the registry API. - // Managed sources do not sync from external sources. - // At most one managed source is allowed per MCPRegistry. - // Mutually exclusive with ConfigMapRef, Git, API, URL, and Kubernetes - // +optional - Managed *ManagedSource `json:"managed,omitempty"` - - // Kubernetes defines a source that discovers MCP servers from running Kubernetes resources. - // Mutually exclusive with ConfigMapRef, Git, API, URL, and Managed - // +optional - Kubernetes *KubernetesSource `json:"kubernetes,omitempty"` - - // SyncPolicy defines the automatic synchronization behavior for this source. - // If specified, enables automatic synchronization at the given interval. - // Manual synchronization is always supported via annotation-based triggers - // regardless of this setting. - // Not applicable for Managed and Kubernetes sources (will be ignored). - // +optional - SyncPolicy *SyncPolicy `json:"syncPolicy,omitempty"` - - // Filter defines include/exclude patterns for registry content. - // Not applicable for Managed and Kubernetes sources (will be ignored). - // +optional - Filter *RegistryFilter `json:"filter,omitempty"` -} - -// MCPRegistryViewConfig defines a lightweight registry view that aggregates one or more sources. -type MCPRegistryViewConfig struct { - // Name is a unique identifier for this registry view - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` - - // Sources is an ordered list of source names that feed this registry. - // Each name must reference a source defined in spec.sources. - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinItems=1 - // +listType=atomic - Sources []string `json:"sources"` - - // Claims are key-value pairs that gate access to this registry view. - // Only requests with matching claims can access this registry. Values must be string or []string. - // +optional - // +kubebuilder:pruning:PreserveUnknownFields - // +kubebuilder:validation:Type=object - Claims *apiextensionsv1.JSON `json:"claims,omitempty"` -} - -// URLSource defines a URL-hosted file source configuration. -// The registry server fetches registry data from the specified HTTP/HTTPS URL. -type URLSource struct { - // Endpoint is the HTTP/HTTPS URL to fetch the registry file from. - // HTTPS is required unless the host is localhost. - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:Pattern="^https?://.*" - Endpoint string `json:"endpoint"` - - // Timeout is the timeout for HTTP requests (Go duration format). - // Defaults to "30s" if not specified. - // +kubebuilder:validation:Pattern=^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - // +optional - Timeout string `json:"timeout,omitempty"` -} - -// ManagedSource defines a managed source that is directly manipulated via the registry API. -// Managed sources do not sync from external sources. -type ManagedSource struct { - // Empty — presence indicates this is a managed (internal) source for publishing -} - -// KubernetesSource defines a source that discovers MCP servers from running Kubernetes resources. -// Per-entry claims can be set on CRDs via the toolhive.stacklok.dev/authz-claims JSON annotation. -type KubernetesSource struct { - // Namespaces is a list of Kubernetes namespaces to watch for MCP servers. - // If empty, watches the operator's configured namespace. - // +listType=atomic - // +optional - Namespaces []string `json:"namespaces,omitempty"` -} - -// GitSource defines Git repository source configuration -type GitSource struct { - // Repository is the Git repository URL (HTTP/HTTPS/SSH) - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:Pattern="^(file:///|https?://|git@|ssh://|git://).*" - Repository string `json:"repository"` - - // Branch is the Git branch to use (mutually exclusive with Tag and Commit) - // +kubebuilder:validation:MinLength=1 - // +optional - Branch string `json:"branch,omitempty"` - - // Tag is the Git tag to use (mutually exclusive with Branch and Commit) - // +kubebuilder:validation:MinLength=1 - // +optional - Tag string `json:"tag,omitempty"` - - // Commit is the Git commit SHA to use (mutually exclusive with Branch and Tag) - // +kubebuilder:validation:MinLength=1 - // +optional - Commit string `json:"commit,omitempty"` - - // Path is the path to the registry file within the repository - // +kubebuilder:validation:Pattern=^.*\.json$ - // +kubebuilder:default=registry.json - // +optional - Path string `json:"path,omitempty"` - - // Auth defines optional authentication for private Git repositories. - // When specified, enables HTTP Basic authentication using the provided - // username and password/token from a Kubernetes Secret. - // +optional - Auth *GitAuthConfig `json:"auth,omitempty"` -} - -// GitAuthConfig defines authentication settings for private Git repositories. -// Uses HTTP Basic authentication with username and password/token. -// The password is stored in a Kubernetes Secret and mounted as a file -// for the registry server to read. -type GitAuthConfig struct { - // Username is the Git username for HTTP Basic authentication. - // For GitHub/GitLab token-based auth, this is typically the literal string "git" - // or the token itself depending on the provider. - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Username string `json:"username"` - - // PasswordSecretRef references a Kubernetes Secret containing the password or token - // for Git authentication. The secret value will be mounted as a file and its path - // passed to the registry server via the git.auth.passwordFile configuration. - // - // Example secret: - // apiVersion: v1 - // kind: Secret - // metadata: - // name: git-credentials - // stringData: - // token: - // - // Then reference it as: - // passwordSecretRef: - // name: git-credentials - // key: token - // - // +kubebuilder:validation:Required - PasswordSecretRef corev1.SecretKeySelector `json:"passwordSecretRef"` -} - -// APISource defines API source configuration for ToolHive Registry APIs -// Phase 1: Supports ToolHive API endpoints (no pagination) -// Phase 2: Will add support for upstream MCP Registry API with pagination -type APISource struct { - // Endpoint is the base API URL (without path) - // The controller will append the appropriate paths: - // Phase 1 (ToolHive API): - // - /v0/servers - List all servers (single response, no pagination) - // - /v0/servers/{name} - Get specific server (future) - // - /v0/info - Get registry metadata (future) - // Example: "http://my-registry-api.default.svc.cluster.local/api" - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:Pattern="^https?://.*" - Endpoint string `json:"endpoint"` -} - -// SyncPolicy defines automatic synchronization behavior. -// When specified, enables automatic synchronization at the given interval. -// Manual synchronization via annotation-based triggers is always available -// regardless of this policy setting. -type SyncPolicy struct { - // Interval is the sync interval for automatic synchronization (Go duration format) - // Examples: "1h", "30m", "24h" - // +kubebuilder:validation:Pattern=^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - // +kubebuilder:validation:Required - Interval string `json:"interval"` -} - -// RegistryFilter defines include/exclude patterns for registry content -type RegistryFilter struct { - // NameFilters defines name-based filtering - // +optional - NameFilters *NameFilter `json:"names,omitempty"` - - // Tags defines tag-based filtering - // +optional - Tags *TagFilter `json:"tags,omitempty"` -} - -// NameFilter defines name-based filtering -type NameFilter struct { - // Include is a list of glob patterns to include - // +listType=atomic - // +optional - Include []string `json:"include,omitempty"` - - // Exclude is a list of glob patterns to exclude - // +listType=atomic - // +optional - Exclude []string `json:"exclude,omitempty"` -} - -// TagFilter defines tag-based filtering -type TagFilter struct { - // Include is a list of tags to include - // +listType=atomic - // +optional - Include []string `json:"include,omitempty"` - - // Exclude is a list of tags to exclude - // +listType=atomic - // +optional - Exclude []string `json:"exclude,omitempty"` -} - -// MCPRegistryDatabaseConfig defines PostgreSQL database configuration for the registry API server. -// Uses a two-user security model: separate users for operations and migrations. -type MCPRegistryDatabaseConfig struct { - // Host is the database server hostname - // +kubebuilder:default="postgres" - // +optional - Host string `json:"host,omitempty"` - - // Port is the database server port - // +kubebuilder:default=5432 - // +kubebuilder:validation:Minimum=1 - // +kubebuilder:validation:Maximum=65535 - // +optional - Port int32 `json:"port,omitempty"` - - // User is the application user (limited privileges: SELECT, INSERT, UPDATE, DELETE) - // Credentials should be provided via pgpass file or environment variables - // +kubebuilder:default="db_app" - // +optional - User string `json:"user,omitempty"` - - // MigrationUser is the migration user (elevated privileges: CREATE, ALTER, DROP) - // Used for running database schema migrations - // Credentials should be provided via pgpass file or environment variables - // +kubebuilder:default="db_migrator" - // +optional - MigrationUser string `json:"migrationUser,omitempty"` - - // Database is the database name - // +kubebuilder:default="registry" - // +optional - Database string `json:"database,omitempty"` - - // SSLMode is the SSL mode for the connection - // Valid values: disable, allow, prefer, require, verify-ca, verify-full - // +kubebuilder:validation:Enum=disable;allow;prefer;require;verify-ca;verify-full - // +kubebuilder:default="prefer" - // +optional - SSLMode string `json:"sslMode,omitempty"` - - // MaxOpenConns is the maximum number of open connections to the database - // +kubebuilder:default=10 - // +kubebuilder:validation:Minimum=1 - // +optional - MaxOpenConns int32 `json:"maxOpenConns,omitempty"` - - // MaxIdleConns is the maximum number of idle connections in the pool - // +kubebuilder:default=2 - // +kubebuilder:validation:Minimum=0 - // +optional - MaxIdleConns int32 `json:"maxIdleConns,omitempty"` - - // ConnMaxLifetime is the maximum amount of time a connection may be reused (Go duration format) - // Examples: "30m", "1h", "24h" - // +kubebuilder:validation:Pattern=^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - // +kubebuilder:default="30m" - // +optional - ConnMaxLifetime string `json:"connMaxLifetime,omitempty"` - - // MaxMetaSize is the maximum allowed size in bytes for publisher-provided - // metadata extensions (_meta). Must be greater than zero. - // Defaults to 262144 (256KB) if not specified. - // +kubebuilder:validation:Minimum=1 - // +optional - MaxMetaSize *int32 `json:"maxMetaSize,omitempty"` - - // DynamicAuth defines dynamic database authentication configuration. - // When set, the registry server authenticates to the database using - // short-lived credentials instead of static passwords. - // +optional - DynamicAuth *MCPRegistryDynamicAuthConfig `json:"dynamicAuth,omitempty"` - - // DBAppUserPasswordSecretRef references a Kubernetes Secret containing the password for the application database user. - // The operator will use this password along with DBMigrationUserPasswordSecretRef to generate a pgpass file - // that is mounted to the registry API container. - // - // +kubebuilder:validation:Required - DBAppUserPasswordSecretRef corev1.SecretKeySelector `json:"dbAppUserPasswordSecretRef"` - - // DBMigrationUserPasswordSecretRef references a Kubernetes Secret containing the password for the migration database user. - // The operator will use this password along with DBAppUserPasswordSecretRef to generate a pgpass file - // that is mounted to the registry API container. - // - // +kubebuilder:validation:Required - DBMigrationUserPasswordSecretRef corev1.SecretKeySelector `json:"dbMigrationUserPasswordSecretRef"` -} - -// MCPRegistryDynamicAuthConfig defines dynamic database authentication configuration. -type MCPRegistryDynamicAuthConfig struct { - // AWSRDSIAM enables AWS RDS IAM authentication for database connections. - // +optional - AWSRDSIAM *MCPRegistryAWSRDSIAMConfig `json:"awsRdsIam,omitempty"` -} - -// MCPRegistryAWSRDSIAMConfig defines AWS RDS IAM authentication configuration. -type MCPRegistryAWSRDSIAMConfig struct { - // Region is the AWS region for RDS IAM authentication. - // Use "detect" to automatically detect the region from instance metadata. - // +kubebuilder:validation:MinLength=1 - // +optional - Region string `json:"region,omitempty"` -} - -// MCPRegistryAuthMode represents the authentication mode for the registry API server -type MCPRegistryAuthMode string - -const ( - // MCPRegistryAuthModeAnonymous allows unauthenticated access - MCPRegistryAuthModeAnonymous MCPRegistryAuthMode = "anonymous" - - // MCPRegistryAuthModeOAuth enables OAuth/OIDC authentication - MCPRegistryAuthModeOAuth MCPRegistryAuthMode = "oauth" -) - -// MCPRegistryAuthConfig defines authentication configuration for the registry API server. -// -// +kubebuilder:validation:XValidation:rule="self.mode != 'anonymous' || !has(self.authz)",message="authz configuration has no effect when auth mode is anonymous" -// -//nolint:lll // CEL validation rules exceed line length limit -type MCPRegistryAuthConfig struct { - // Mode specifies the authentication mode (anonymous or oauth) - // Defaults to "anonymous" if not specified. - // Use "oauth" to enable OAuth/OIDC authentication. - // +kubebuilder:validation:Enum=anonymous;oauth - // +kubebuilder:default="anonymous" - // +optional - Mode MCPRegistryAuthMode `json:"mode,omitempty"` - - // PublicPaths defines additional paths that bypass authentication. - // These extend the default public paths (health, docs, swagger, well-known). - // Each path must start with "/". Do not add API data paths here. - // Example: ["/custom/public", "/metrics"] - // +kubebuilder:validation:items:MinLength=1 - // +kubebuilder:validation:items:Pattern="^/" - // +listType=atomic - // +optional - PublicPaths []string `json:"publicPaths,omitempty"` - - // OAuth defines OAuth/OIDC specific authentication settings - // Only used when Mode is "oauth" - // +optional - OAuth *MCPRegistryOAuthConfig `json:"oauth,omitempty"` - - // Authz defines authorization configuration for role-based access control. - // +optional - Authz *MCPRegistryAuthzConfig `json:"authz,omitempty"` -} - -// MCPRegistryAuthzConfig defines authorization configuration for role-based access control -type MCPRegistryAuthzConfig struct { - // Roles defines the role-based authorization rules. - // Each role is a list of claim matchers (JSON objects with string or []string values). - // +optional - Roles MCPRegistryRolesConfig `json:"roles,omitempty"` -} - -// MCPRegistryRolesConfig defines role-based authorization rules. -// Each role is a list of claim matchers — a request matching any entry in the list is granted the role. -type MCPRegistryRolesConfig struct { - // SuperAdmin grants full administrative access to the registry. - // +optional - // +listType=atomic - SuperAdmin []apiextensionsv1.JSON `json:"superAdmin,omitempty"` - - // ManageSources grants permission to create, update, and delete sources. - // +optional - // +listType=atomic - ManageSources []apiextensionsv1.JSON `json:"manageSources,omitempty"` - - // ManageRegistries grants permission to create, update, and delete registries. - // +optional - // +listType=atomic - ManageRegistries []apiextensionsv1.JSON `json:"manageRegistries,omitempty"` - - // ManageEntries grants permission to create, update, and delete registry entries. - // +optional - // +listType=atomic - ManageEntries []apiextensionsv1.JSON `json:"manageEntries,omitempty"` -} - -// MCPRegistryOAuthConfig defines OAuth/OIDC specific authentication settings -type MCPRegistryOAuthConfig struct { - // ResourceURL is the URL identifying this protected resource (RFC 9728) - // Used in the /.well-known/oauth-protected-resource endpoint - // +optional - ResourceURL string `json:"resourceUrl,omitempty"` - - // Providers defines the OAuth/OIDC providers for authentication - // Multiple providers can be configured (e.g., Kubernetes + external IDP) - // +kubebuilder:validation:MinItems=1 - // +listType=map - // +listMapKey=name - // +optional - Providers []MCPRegistryOAuthProviderConfig `json:"providers,omitempty"` - - // ScopesSupported defines the OAuth scopes supported by this resource (RFC 9728) - // Defaults to ["mcp-registry:read", "mcp-registry:write"] if not specified - // +listType=atomic - // +optional - ScopesSupported []string `json:"scopesSupported,omitempty"` - - // Realm is the protection space identifier for WWW-Authenticate header (RFC 7235) - // Defaults to "mcp-registry" if not specified - // +optional - Realm string `json:"realm,omitempty"` -} - -// MCPRegistryOAuthProviderConfig defines configuration for an OAuth/OIDC provider -type MCPRegistryOAuthProviderConfig struct { - // Name is a unique identifier for this provider (e.g., "kubernetes", "keycloak") - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` - - // IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com) - // The JWKS URL will be discovered automatically from .well-known/openid-configuration - // unless JwksUrl is explicitly specified - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:Pattern="^https?://.*" - IssuerURL string `json:"issuerUrl"` - - // JwksUrl is the URL to fetch the JSON Web Key Set (JWKS) from - // If specified, OIDC discovery is skipped and this URL is used directly - // Example: https://kubernetes.default.svc/openid/v1/jwks - // +kubebuilder:validation:Pattern="^https?://.*" - // +optional - JwksUrl string `json:"jwksUrl,omitempty"` - - // Audience is the expected audience claim in the token (REQUIRED) - // Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience - // For Kubernetes, this is typically the API server URL - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - Audience string `json:"audience"` - - // ClientID is the OAuth client ID for token introspection (optional) - // +optional - ClientID string `json:"clientId,omitempty"` - - // ClientSecretRef is a reference to a Secret containing the client secret - // The secret should have a key "clientSecret" containing the secret value - // +optional - ClientSecretRef *corev1.SecretKeySelector `json:"clientSecretRef,omitempty"` - - // CACertRef is a reference to a ConfigMap containing the CA certificate bundle - // for verifying the provider's TLS certificate. - // Required for Kubernetes in-cluster authentication or self-signed certificates - // +optional - CACertRef *corev1.ConfigMapKeySelector `json:"caCertRef,omitempty"` - - // CaCertPath is the path to the CA certificate bundle for verifying the provider's TLS certificate. - // Required for Kubernetes in-cluster authentication or self-signed certificates - // +optional - CaCertPath string `json:"caCertPath,omitempty"` - - // AuthTokenRef is a reference to a Secret containing a bearer token for authenticating - // to OIDC/JWKS endpoints. Useful when the OIDC discovery or JWKS endpoint requires authentication. - // Example: ServiceAccount token for Kubernetes API server - // +optional - AuthTokenRef *corev1.SecretKeySelector `json:"authTokenRef,omitempty"` - - // AuthTokenFile is the path to a file containing a bearer token for authenticating to OIDC/JWKS endpoints. - // Useful when the OIDC discovery or JWKS endpoint requires authentication. - // Example: /var/run/secrets/kubernetes.io/serviceaccount/token - // +optional - AuthTokenFile string `json:"authTokenFile,omitempty"` - - // IntrospectionURL is the OAuth 2.0 Token Introspection endpoint (RFC 7662) - // Used for validating opaque (non-JWT) tokens - // If not specified, only JWT tokens can be validated via JWKS - // +kubebuilder:validation:Pattern="^https?://.*" - // +optional - IntrospectionURL string `json:"introspectionUrl,omitempty"` - - // AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - // Required when the OAuth provider (e.g., Kubernetes API server) is running on a private network - // Example: Set to true when using https://kubernetes.default.svc as the issuer URL - // +kubebuilder:default=false - // +optional - AllowPrivateIP bool `json:"allowPrivateIP,omitempty"` -} - -// MCPRegistryTelemetryConfig defines OpenTelemetry configuration for the registry API server. -type MCPRegistryTelemetryConfig struct { - // Enabled controls whether telemetry is enabled globally. - // When false, no telemetry providers are initialized. - // +kubebuilder:default=false - // +optional - Enabled bool `json:"enabled,omitempty"` - - // ServiceName is the name of the service for telemetry identification. - // Defaults to "thv-registry-api" if not specified. - // +optional - ServiceName string `json:"serviceName,omitempty"` - - // ServiceVersion is the version of the service for telemetry identification. - // +optional - ServiceVersion string `json:"serviceVersion,omitempty"` - - // Endpoint is the OTLP collector endpoint (host:port). - // Defaults to "localhost:4318" if not specified. - // +optional - Endpoint string `json:"endpoint,omitempty"` - - // Insecure allows HTTP connections instead of HTTPS to the OTLP endpoint. - // Should only be true for development/testing environments. - // +kubebuilder:default=false - // +optional - Insecure bool `json:"insecure,omitempty"` - - // Tracing defines tracing-specific configuration. - // +optional - Tracing *MCPRegistryTracingConfig `json:"tracing,omitempty"` - - // Metrics defines metrics-specific configuration. - // +optional - Metrics *MCPRegistryMetricsConfig `json:"metrics,omitempty"` -} - -// MCPRegistryTracingConfig defines tracing-specific configuration. -type MCPRegistryTracingConfig struct { - // Enabled controls whether tracing is enabled. - // +kubebuilder:default=false - // +optional - Enabled bool `json:"enabled,omitempty"` - - // Sampling controls the trace sampling rate (0.0 to 1.0, exclusive of 0.0). - // 1.0 means sample all traces, 0.5 means sample 50%. - // Defaults to 0.05 (5%) if not specified. - // +optional - Sampling *string `json:"sampling,omitempty"` -} - -// MCPRegistryMetricsConfig defines metrics-specific configuration. -type MCPRegistryMetricsConfig struct { - // Enabled controls whether metrics collection is enabled. - // +kubebuilder:default=false - // +optional - Enabled bool `json:"enabled,omitempty"` } // MCPRegistryStatus defines the observed state of MCPRegistry @@ -858,9 +184,6 @@ const ( //+kubebuilder:printcolumn:name="URL",type="string",JSONPath=".status.url" //+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" //+kubebuilder:resource:shortName=mcpreg;registry,scope=Namespaced,categories=toolhive -//nolint:lll -//+kubebuilder:validation:XValidation:rule="size(self.spec.configYAML) > 0 || (has(self.spec.sources) && size(self.spec.sources) > 0)",message="either configYAML or sources must be specified" -//+kubebuilder:validation:XValidation:rule="!has(self.spec.sources) || self.spec.sources.filter(s, has(s.managed)).size() <= 1",message="at most one managed source is allowed" // MCPRegistry is the Schema for the mcpregistries API type MCPRegistry struct { @@ -904,67 +227,6 @@ func (r *MCPRegistry) GetPodTemplateSpecRaw() *runtime.RawExtension { return r.Spec.PodTemplateSpec } -// BuildPGPassSecretName returns the name of the generated pgpass secret for this registry -func (r *MCPRegistry) BuildPGPassSecretName() string { - return fmt.Sprintf("%s-db-pgpass", r.Name) -} - -// HasDatabaseConfig returns true if the MCPRegistry has a valid database configuration. -// A valid configuration requires: -// - DatabaseConfig to be non-nil -// - Host to be specified -// - Database to be specified -// - User to be specified -// - MigrationUser to be specified -// - DBAppUserPasswordSecretRef.Name to be specified -// - DBMigrationUserPasswordSecretRef.Name to be specified -func (r *MCPRegistry) HasDatabaseConfig() bool { - if r.Spec.DatabaseConfig == nil { - return false - } - - dbConfig := r.Spec.DatabaseConfig - - // All required fields must be specified - if dbConfig.Host == "" { - return false - } - if dbConfig.Database == "" { - return false - } - if dbConfig.User == "" { - return false - } - if dbConfig.MigrationUser == "" { - return false - } - if dbConfig.DBAppUserPasswordSecretRef.Name == "" { - return false - } - if dbConfig.DBMigrationUserPasswordSecretRef.Name == "" { - return false - } - - return true -} - -// GetDatabaseConfig returns the database configuration. -// Callers should check HasDatabaseConfig() before calling this method. -func (r *MCPRegistry) GetDatabaseConfig() *MCPRegistryDatabaseConfig { - return r.Spec.DatabaseConfig -} - -// GetDatabasePort returns the database port. -// If the port is not specified, it returns 5432. -// We do this because its likely to be 5432 due to -// it being the default port for PostgreSQL. -func (r *MCPRegistry) GetDatabasePort() int32 { - if r.Spec.DatabaseConfig == nil || r.Spec.DatabaseConfig.Port == 0 { - return 5432 - } - return r.Spec.DatabaseConfig.Port -} - // ParseVolumes deserializes the raw JSON Volumes into typed corev1.Volume objects. // Returns an empty slice if Volumes is nil or empty. func (s *MCPRegistrySpec) ParseVolumes() ([]corev1.Volume, error) { diff --git a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go index 689d9311c1..e797bf1d23 100644 --- a/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -27,21 +27,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *APISource) DeepCopyInto(out *APISource) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APISource. -func (in *APISource) DeepCopy() *APISource { - if in == nil { - return nil - } - out := new(APISource) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSStsConfig) DeepCopyInto(out *AWSStsConfig) { *out = *in @@ -509,42 +494,6 @@ func (in *ExternalAuthConfigRef) DeepCopy() *ExternalAuthConfigRef { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GitAuthConfig) DeepCopyInto(out *GitAuthConfig) { - *out = *in - in.PasswordSecretRef.DeepCopyInto(&out.PasswordSecretRef) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitAuthConfig. -func (in *GitAuthConfig) DeepCopy() *GitAuthConfig { - if in == nil { - return nil - } - out := new(GitAuthConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GitSource) DeepCopyInto(out *GitSource) { - *out = *in - if in.Auth != nil { - in, out := &in.Auth, &out.Auth - *out = new(GitAuthConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitSource. -func (in *GitSource) DeepCopy() *GitSource { - if in == nil { - return nil - } - out := new(GitSource) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HeaderForwardConfig) DeepCopyInto(out *HeaderForwardConfig) { *out = *in @@ -759,26 +708,6 @@ func (in *KubernetesServiceAccountOIDCConfig) DeepCopy() *KubernetesServiceAccou return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KubernetesSource) DeepCopyInto(out *KubernetesSource) { - *out = *in - if in.Namespaces != nil { - in, out := &in.Namespaces, &out.Namespaces - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesSource. -func (in *KubernetesSource) DeepCopy() *KubernetesSource { - if in == nil { - return nil - } - out := new(KubernetesSource) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPExternalAuthConfig) DeepCopyInto(out *MCPExternalAuthConfig) { *out = *in @@ -1179,114 +1108,6 @@ func (in *MCPRegistry) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryAWSRDSIAMConfig) DeepCopyInto(out *MCPRegistryAWSRDSIAMConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryAWSRDSIAMConfig. -func (in *MCPRegistryAWSRDSIAMConfig) DeepCopy() *MCPRegistryAWSRDSIAMConfig { - if in == nil { - return nil - } - out := new(MCPRegistryAWSRDSIAMConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryAuthConfig) DeepCopyInto(out *MCPRegistryAuthConfig) { - *out = *in - if in.PublicPaths != nil { - in, out := &in.PublicPaths, &out.PublicPaths - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.OAuth != nil { - in, out := &in.OAuth, &out.OAuth - *out = new(MCPRegistryOAuthConfig) - (*in).DeepCopyInto(*out) - } - if in.Authz != nil { - in, out := &in.Authz, &out.Authz - *out = new(MCPRegistryAuthzConfig) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryAuthConfig. -func (in *MCPRegistryAuthConfig) DeepCopy() *MCPRegistryAuthConfig { - if in == nil { - return nil - } - out := new(MCPRegistryAuthConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryAuthzConfig) DeepCopyInto(out *MCPRegistryAuthzConfig) { - *out = *in - in.Roles.DeepCopyInto(&out.Roles) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryAuthzConfig. -func (in *MCPRegistryAuthzConfig) DeepCopy() *MCPRegistryAuthzConfig { - if in == nil { - return nil - } - out := new(MCPRegistryAuthzConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryDatabaseConfig) DeepCopyInto(out *MCPRegistryDatabaseConfig) { - *out = *in - if in.MaxMetaSize != nil { - in, out := &in.MaxMetaSize, &out.MaxMetaSize - *out = new(int32) - **out = **in - } - if in.DynamicAuth != nil { - in, out := &in.DynamicAuth, &out.DynamicAuth - *out = new(MCPRegistryDynamicAuthConfig) - (*in).DeepCopyInto(*out) - } - in.DBAppUserPasswordSecretRef.DeepCopyInto(&out.DBAppUserPasswordSecretRef) - in.DBMigrationUserPasswordSecretRef.DeepCopyInto(&out.DBMigrationUserPasswordSecretRef) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryDatabaseConfig. -func (in *MCPRegistryDatabaseConfig) DeepCopy() *MCPRegistryDatabaseConfig { - if in == nil { - return nil - } - out := new(MCPRegistryDatabaseConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryDynamicAuthConfig) DeepCopyInto(out *MCPRegistryDynamicAuthConfig) { - *out = *in - if in.AWSRDSIAM != nil { - in, out := &in.AWSRDSIAM, &out.AWSRDSIAM - *out = new(MCPRegistryAWSRDSIAMConfig) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryDynamicAuthConfig. -func (in *MCPRegistryDynamicAuthConfig) DeepCopy() *MCPRegistryDynamicAuthConfig { - if in == nil { - return nil - } - out := new(MCPRegistryDynamicAuthConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPRegistryList) DeepCopyInto(out *MCPRegistryList) { *out = *in @@ -1319,181 +1140,6 @@ func (in *MCPRegistryList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryMetricsConfig) DeepCopyInto(out *MCPRegistryMetricsConfig) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryMetricsConfig. -func (in *MCPRegistryMetricsConfig) DeepCopy() *MCPRegistryMetricsConfig { - if in == nil { - return nil - } - out := new(MCPRegistryMetricsConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryOAuthConfig) DeepCopyInto(out *MCPRegistryOAuthConfig) { - *out = *in - if in.Providers != nil { - in, out := &in.Providers, &out.Providers - *out = make([]MCPRegistryOAuthProviderConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ScopesSupported != nil { - in, out := &in.ScopesSupported, &out.ScopesSupported - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryOAuthConfig. -func (in *MCPRegistryOAuthConfig) DeepCopy() *MCPRegistryOAuthConfig { - if in == nil { - return nil - } - out := new(MCPRegistryOAuthConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryOAuthProviderConfig) DeepCopyInto(out *MCPRegistryOAuthProviderConfig) { - *out = *in - if in.ClientSecretRef != nil { - in, out := &in.ClientSecretRef, &out.ClientSecretRef - *out = new(corev1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } - if in.CACertRef != nil { - in, out := &in.CACertRef, &out.CACertRef - *out = new(corev1.ConfigMapKeySelector) - (*in).DeepCopyInto(*out) - } - if in.AuthTokenRef != nil { - in, out := &in.AuthTokenRef, &out.AuthTokenRef - *out = new(corev1.SecretKeySelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryOAuthProviderConfig. -func (in *MCPRegistryOAuthProviderConfig) DeepCopy() *MCPRegistryOAuthProviderConfig { - if in == nil { - return nil - } - out := new(MCPRegistryOAuthProviderConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryRolesConfig) DeepCopyInto(out *MCPRegistryRolesConfig) { - *out = *in - if in.SuperAdmin != nil { - in, out := &in.SuperAdmin, &out.SuperAdmin - *out = make([]apiextensionsv1.JSON, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ManageSources != nil { - in, out := &in.ManageSources, &out.ManageSources - *out = make([]apiextensionsv1.JSON, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ManageRegistries != nil { - in, out := &in.ManageRegistries, &out.ManageRegistries - *out = make([]apiextensionsv1.JSON, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.ManageEntries != nil { - in, out := &in.ManageEntries, &out.ManageEntries - *out = make([]apiextensionsv1.JSON, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryRolesConfig. -func (in *MCPRegistryRolesConfig) DeepCopy() *MCPRegistryRolesConfig { - if in == nil { - return nil - } - out := new(MCPRegistryRolesConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistrySourceConfig) DeepCopyInto(out *MCPRegistrySourceConfig) { - *out = *in - if in.Claims != nil { - in, out := &in.Claims, &out.Claims - *out = new(apiextensionsv1.JSON) - (*in).DeepCopyInto(*out) - } - if in.ConfigMapRef != nil { - in, out := &in.ConfigMapRef, &out.ConfigMapRef - *out = new(corev1.ConfigMapKeySelector) - (*in).DeepCopyInto(*out) - } - if in.Git != nil { - in, out := &in.Git, &out.Git - *out = new(GitSource) - (*in).DeepCopyInto(*out) - } - if in.API != nil { - in, out := &in.API, &out.API - *out = new(APISource) - **out = **in - } - if in.URL != nil { - in, out := &in.URL, &out.URL - *out = new(URLSource) - **out = **in - } - if in.Managed != nil { - in, out := &in.Managed, &out.Managed - *out = new(ManagedSource) - **out = **in - } - if in.Kubernetes != nil { - in, out := &in.Kubernetes, &out.Kubernetes - *out = new(KubernetesSource) - (*in).DeepCopyInto(*out) - } - if in.SyncPolicy != nil { - in, out := &in.SyncPolicy, &out.SyncPolicy - *out = new(SyncPolicy) - **out = **in - } - if in.Filter != nil { - in, out := &in.Filter, &out.Filter - *out = new(RegistryFilter) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistrySourceConfig. -func (in *MCPRegistrySourceConfig) DeepCopy() *MCPRegistrySourceConfig { - if in == nil { - return nil - } - out := new(MCPRegistrySourceConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPRegistrySpec) DeepCopyInto(out *MCPRegistrySpec) { *out = *in @@ -1521,35 +1167,6 @@ func (in *MCPRegistrySpec) DeepCopyInto(out *MCPRegistrySpec) { *out = new(runtime.RawExtension) (*in).DeepCopyInto(*out) } - if in.Sources != nil { - in, out := &in.Sources, &out.Sources - *out = make([]MCPRegistrySourceConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.Registries != nil { - in, out := &in.Registries, &out.Registries - *out = make([]MCPRegistryViewConfig, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } - if in.DatabaseConfig != nil { - in, out := &in.DatabaseConfig, &out.DatabaseConfig - *out = new(MCPRegistryDatabaseConfig) - (*in).DeepCopyInto(*out) - } - if in.AuthConfig != nil { - in, out := &in.AuthConfig, &out.AuthConfig - *out = new(MCPRegistryAuthConfig) - (*in).DeepCopyInto(*out) - } - if in.TelemetryConfig != nil { - in, out := &in.TelemetryConfig, &out.TelemetryConfig - *out = new(MCPRegistryTelemetryConfig) - (*in).DeepCopyInto(*out) - } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistrySpec. @@ -1584,76 +1201,6 @@ func (in *MCPRegistryStatus) DeepCopy() *MCPRegistryStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryTelemetryConfig) DeepCopyInto(out *MCPRegistryTelemetryConfig) { - *out = *in - if in.Tracing != nil { - in, out := &in.Tracing, &out.Tracing - *out = new(MCPRegistryTracingConfig) - (*in).DeepCopyInto(*out) - } - if in.Metrics != nil { - in, out := &in.Metrics, &out.Metrics - *out = new(MCPRegistryMetricsConfig) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryTelemetryConfig. -func (in *MCPRegistryTelemetryConfig) DeepCopy() *MCPRegistryTelemetryConfig { - if in == nil { - return nil - } - out := new(MCPRegistryTelemetryConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryTracingConfig) DeepCopyInto(out *MCPRegistryTracingConfig) { - *out = *in - if in.Sampling != nil { - in, out := &in.Sampling, &out.Sampling - *out = new(string) - **out = **in - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryTracingConfig. -func (in *MCPRegistryTracingConfig) DeepCopy() *MCPRegistryTracingConfig { - if in == nil { - return nil - } - out := new(MCPRegistryTracingConfig) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *MCPRegistryViewConfig) DeepCopyInto(out *MCPRegistryViewConfig) { - *out = *in - if in.Sources != nil { - in, out := &in.Sources, &out.Sources - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Claims != nil { - in, out := &in.Claims, &out.Claims - *out = new(apiextensionsv1.JSON) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPRegistryViewConfig. -func (in *MCPRegistryViewConfig) DeepCopy() *MCPRegistryViewConfig { - if in == nil { - return nil - } - out := new(MCPRegistryViewConfig) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPRemoteProxy) DeepCopyInto(out *MCPRemoteProxy) { *out = *in @@ -2407,21 +1954,6 @@ func (in *MCPToolConfigStatus) DeepCopy() *MCPToolConfigStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ManagedSource) DeepCopyInto(out *ManagedSource) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedSource. -func (in *ManagedSource) DeepCopy() *ManagedSource { - if in == nil { - return nil - } - out := new(ManagedSource) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ModelCacheConfig) DeepCopyInto(out *ModelCacheConfig) { *out = *in @@ -2442,31 +1974,6 @@ func (in *ModelCacheConfig) DeepCopy() *ModelCacheConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NameFilter) DeepCopyInto(out *NameFilter) { - *out = *in - if in.Include != nil { - in, out := &in.Include, &out.Include - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Exclude != nil { - in, out := &in.Exclude, &out.Exclude - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NameFilter. -func (in *NameFilter) DeepCopy() *NameFilter { - if in == nil { - return nil - } - out := new(NameFilter) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkPermissions) DeepCopyInto(out *NetworkPermissions) { *out = *in @@ -2933,31 +2440,6 @@ func (in *RedisTLSConfig) DeepCopy() *RedisTLSConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RegistryFilter) DeepCopyInto(out *RegistryFilter) { - *out = *in - if in.NameFilters != nil { - in, out := &in.NameFilters, &out.NameFilters - *out = new(NameFilter) - (*in).DeepCopyInto(*out) - } - if in.Tags != nil { - in, out := &in.Tags, &out.Tags - *out = new(TagFilter) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistryFilter. -func (in *RegistryFilter) DeepCopy() *RegistryFilter { - if in == nil { - return nil - } - out := new(RegistryFilter) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceList) DeepCopyInto(out *ResourceList) { *out = *in @@ -3145,46 +2627,6 @@ func (in *SessionStorageConfig) DeepCopy() *SessionStorageConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SyncPolicy) DeepCopyInto(out *SyncPolicy) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncPolicy. -func (in *SyncPolicy) DeepCopy() *SyncPolicy { - if in == nil { - return nil - } - out := new(SyncPolicy) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *TagFilter) DeepCopyInto(out *TagFilter) { - *out = *in - if in.Include != nil { - in, out := &in.Include, &out.Include - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Exclude != nil { - in, out := &in.Exclude, &out.Exclude - *out = make([]string, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TagFilter. -func (in *TagFilter) DeepCopy() *TagFilter { - if in == nil { - return nil - } - out := new(TagFilter) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TelemetryConfig) DeepCopyInto(out *TelemetryConfig) { *out = *in @@ -3360,21 +2802,6 @@ func (in *ToolRateLimitConfig) DeepCopy() *ToolRateLimitConfig { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *URLSource) DeepCopyInto(out *URLSource) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new URLSource. -func (in *URLSource) DeepCopy() *URLSource { - if in == nil { - return nil - } - out := new(URLSource) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpstreamInjectSpec) DeepCopyInto(out *UpstreamInjectSpec) { *out = *in diff --git a/cmd/thv-operator/controllers/mcpregistry_controller.go b/cmd/thv-operator/controllers/mcpregistry_controller.go index dbdddc35e7..dc376fcd72 100644 --- a/cmd/thv-operator/controllers/mcpregistry_controller.go +++ b/cmd/thv-operator/controllers/mcpregistry_controller.go @@ -121,8 +121,8 @@ func (r *MCPRegistryReconciler) Reconcile(ctx context.Context, req ctrl.Request) } } - // Validate new-path spec fields (mutual exclusivity, reserved names, mount paths) - if err := validateNewPathSpec(mcpRegistry); err != nil { + // Validate spec fields (reserved names, mount paths, pgpassSecretRef) + if err := validateSpec(mcpRegistry); err != nil { mcpRegistry.Status.Phase = mcpv1alpha1.MCPRegistryPhaseFailed mcpRegistry.Status.Message = fmt.Sprintf("Spec validation failed: %v", err) setRegistryReadyCondition(mcpRegistry, metav1.ConditionFalse, @@ -314,24 +314,14 @@ func (r *MCPRegistryReconciler) finalizeMCPRegistry(ctx context.Context, registr return nil } -// validateNewPathSpec validates MCPRegistry spec fields for mutual exclusivity, -// reserved resource name conflicts, and mount path collisions. Returns nil if -// the spec is valid or a descriptive error if validation fails. CEL admission -// rules cover the common cases; this is defense-in-depth inside the reconciler. -// -//nolint:staticcheck // Intentionally references deprecated fields for mutual exclusivity validation -func validateNewPathSpec(mcpRegistry *mcpv1alpha1.MCPRegistry) error { +// validateSpec validates MCPRegistry spec fields for reserved resource name +// conflicts, mount path collisions, and pgpassSecretRef completeness. Returns +// nil if the spec is valid or a descriptive error if validation fails. CEL +// admission rules cover the common cases; this is defense-in-depth inside the +// reconciler. +func validateSpec(mcpRegistry *mcpv1alpha1.MCPRegistry) error { spec := &mcpRegistry.Spec - if err := validatePathExclusivity(spec); err != nil { - return err - } - - // Remaining validations only apply to the new configYAML path - if spec.ConfigYAML == "" { - return nil - } - // Parse user PodTemplateSpec once for subsequent checks var userPTS *corev1.PodTemplateSpec if mcpRegistry.HasPodTemplateSpec() { @@ -345,36 +335,8 @@ func validateNewPathSpec(mcpRegistry *mcpv1alpha1.MCPRegistry) error { return err } - return validateMountPathCollisions(spec, userPTS) -} - -// validatePathExclusivity checks mutual exclusivity between new and legacy config paths, -// and that new-path-only fields are not set without configYAML. -// -//nolint:staticcheck // Intentionally references deprecated fields for mutual exclusivity validation -func validatePathExclusivity(spec *mcpv1alpha1.MCPRegistrySpec) error { - hasNewPath := spec.ConfigYAML != "" - hasLegacySources := len(spec.Sources) > 0 || len(spec.Registries) > 0 - hasLegacyConfig := spec.DatabaseConfig != nil || spec.AuthConfig != nil || spec.TelemetryConfig != nil - - if hasNewPath && (hasLegacySources || hasLegacyConfig) { - return fmt.Errorf( - "configYAML is mutually exclusive with sources, databaseConfig, and authConfig; " + - "use configYAML with volumes/volumeMounts for the decoupled path, " + - "or use sources/databaseConfig/authConfig for the legacy path") - } - - if !hasNewPath && !hasLegacySources { - return fmt.Errorf("either configYAML or sources must be specified") - } - - if !hasNewPath { - if len(spec.Volumes) > 0 || len(spec.VolumeMounts) > 0 { - return fmt.Errorf("volumes and volumeMounts require configYAML to be set") - } - if spec.PGPassSecretRef != nil { - return fmt.Errorf("pgpassSecretRef requires configYAML to be set; use databaseConfig for the legacy path") - } + if err := validateMountPathCollisions(spec, userPTS); err != nil { + return err } return validatePGPassSecretRef(spec.PGPassSecretRef) diff --git a/cmd/thv-operator/controllers/mcpregistry_controller_test.go b/cmd/thv-operator/controllers/mcpregistry_controller_test.go index 959add537f..a03159b060 100644 --- a/cmd/thv-operator/controllers/mcpregistry_controller_test.go +++ b/cmd/thv-operator/controllers/mcpregistry_controller_test.go @@ -54,7 +54,7 @@ func newMCPRegistryTestScheme(t *testing.T) *runtime.Scheme { } // newMCPRegistryWithFinalizer creates an MCPRegistry with the controller finalizer -// and a minimal valid spec (one source) so it passes reconciler validation. +// and a minimal valid spec (configYAML) so it passes reconciler validation. func newMCPRegistryWithFinalizer(name, namespace string) *mcpv1alpha1.MCPRegistry { //nolint:unparam return &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ @@ -63,12 +63,7 @@ func newMCPRegistryWithFinalizer(name, namespace string) *mcpv1alpha1.MCPRegistr Finalizers: []string{"mcpregistry.toolhive.stacklok.dev/finalizer"}, }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, + ConfigYAML: "sources:\n - name: k8s\n format: upstream\n kubernetes: {}\nregistries:\n - name: default\n sources: [\"k8s\"]\ndatabase:\n host: postgres\n port: 5432\n user: db_app\n database: registry\nauth:\n mode: anonymous\n", }, } } @@ -111,12 +106,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { mcpRegistry := &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: registryName, Namespace: registryNamespace}, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, + ConfigYAML: "sources:\n - name: k8s\n kubernetes: {}\n", }, } builder := fake.NewClientBuilder(). @@ -156,12 +146,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { DeletionTimestamp: &now, }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, + ConfigYAML: "sources:\n - name: k8s\n kubernetes: {}\n", }, } builder := fake.NewClientBuilder(). @@ -199,12 +184,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { DeletionTimestamp: &now, }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, + ConfigYAML: "sources:\n - name: k8s\n kubernetes: {}\n", }, } builder := fake.NewClientBuilder(). @@ -465,7 +445,7 @@ func TestMCPRegistryReconciler_Reconcile(t *testing.T) { } } -func TestValidateNewPathSpec(t *testing.T) { +func TestValidateSpec(t *testing.T) { t.Parallel() tests := []struct { @@ -474,101 +454,10 @@ func TestValidateNewPathSpec(t *testing.T) { wantErr string }{ { - name: "valid new path with configYAML and no legacy fields", - spec: mcpv1alpha1.MCPRegistrySpec{ - ConfigYAML: "sources:\n - name: default\n", - }, - }, - { - name: "valid legacy path with sources and no configYAML", - spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, - }, - }, - { - name: "mutual exclusivity configYAML plus sources", - spec: mcpv1alpha1.MCPRegistrySpec{ - ConfigYAML: "sources:\n - name: default\n", - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, - }, - wantErr: "mutually exclusive", - }, - { - name: "mutual exclusivity configYAML plus databaseConfig", - spec: mcpv1alpha1.MCPRegistrySpec{ - ConfigYAML: "sources:\n - name: default\n", - DatabaseConfig: &mcpv1alpha1.MCPRegistryDatabaseConfig{Host: "pg"}, - }, - wantErr: "mutually exclusive", - }, - { - name: "mutual exclusivity configYAML plus authConfig", + name: "valid configYAML with no extra fields", spec: mcpv1alpha1.MCPRegistrySpec{ ConfigYAML: "sources:\n - name: default\n", - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{Mode: mcpv1alpha1.MCPRegistryAuthModeAnonymous}, - }, - wantErr: "mutually exclusive", - }, - { - name: "neither path specified", - spec: mcpv1alpha1.MCPRegistrySpec{}, - wantErr: "either configYAML or sources must be specified", - }, - { - name: "volumes without configYAML", - spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, - Volumes: toRawJSONSlice(t, []corev1.Volume{ - {Name: "extra", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}, - }), - }, - wantErr: "volumes and volumeMounts require configYAML", - }, - { - name: "volumeMounts without configYAML", - spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, - VolumeMounts: toRawJSONSlice(t, []corev1.VolumeMount{ - {Name: "extra", MountPath: "/extra"}, - }), - }, - wantErr: "volumes and volumeMounts require configYAML", - }, - { - name: "pgpassSecretRef without configYAML", - spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "test", ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-cm"}, - Key: "registry.json", - }}, - }, - PGPassSecretRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "pgpass"}, - Key: ".pgpass", - }, }, - wantErr: "pgpassSecretRef requires configYAML", }, { name: "pgpassSecretRef with empty name", @@ -744,7 +633,7 @@ func TestValidateNewPathSpec(t *testing.T) { Spec: tt.spec, } - err := validateNewPathSpec(mcpRegistry) + err := validateSpec(mcpRegistry) if tt.wantErr != "" { require.Error(t, err) diff --git a/cmd/thv-operator/pkg/registryapi/config/config.go b/cmd/thv-operator/pkg/registryapi/config/config.go index fc5f05d303..1f587db657 100644 --- a/cmd/thv-operator/pkg/registryapi/config/config.go +++ b/cmd/thv-operator/pkg/registryapi/config/config.go @@ -1,1137 +1,13 @@ // SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. // SPDX-License-Identifier: Apache-2.0 -// Package config provides management for the registry server configuration +// Package config provides constants and helpers for registry server config file management. package config -import ( - "encoding/json" - "fmt" - "path/filepath" - "strconv" - - "gopkg.in/yaml.v3" - corev1 "k8s.io/api/core/v1" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" -) - -// ConfigManager provides methods to build registry server configuration from MCPRegistry resources -// -//nolint:revive -type ConfigManager interface { - BuildConfig() (*Config, error) - GetRegistryServerConfigMapName() string -} - -// NewConfigManager creates a new instance of ConfigManager -func NewConfigManager(mcpRegistry *mcpv1alpha1.MCPRegistry) ConfigManager { - return &configManager{ - mcpRegistry: mcpRegistry, - } -} - -type configManager struct { - mcpRegistry *mcpv1alpha1.MCPRegistry -} - -func (cm *configManager) GetRegistryServerConfigMapName() string { - return fmt.Sprintf("%s-registry-server-config", cm.mcpRegistry.Name) -} - const ( - // SourceTypeGit is the type for registry data stored in Git repositories - SourceTypeGit = "git" - - // SourceTypeAPI is the type for registry data fetched from API endpoints - SourceTypeAPI = "api" - - // SourceTypeFile is the type for registry data stored in local files - SourceTypeFile = "file" - - // RegistryJSONFilePath is the file path where the registry JSON file will be mounted - RegistryJSONFilePath = "/config/registry" - - // RegistryJSONFileName is the name of the registry JSON file - RegistryJSONFileName = "registry.json" - // RegistryServerConfigFilePath is the file path where the registry server config file will be mounted RegistryServerConfigFilePath = "/config" // RegistryServerConfigFileName is the name of the registry server config file RegistryServerConfigFileName = "config.yaml" ) - -// Config represents the root configuration structure (v2 format) -type Config struct { - Sources []SourceConfig `yaml:"sources"` - Registries []RegistryConfig `yaml:"registries,omitempty"` - Database *DatabaseConfig `yaml:"database,omitempty"` - Auth *AuthConfig `yaml:"auth,omitempty"` - Telemetry *TelemetryConfig `yaml:"telemetry,omitempty"` -} - -// DatabaseConfig defines PostgreSQL database configuration -// Uses two-user security model: separate users for operations and migrations -type DatabaseConfig struct { - // Host is the database server hostname - Host string `yaml:"host"` - - // Port is the database server port - Port int32 `yaml:"port"` - - // User is the application user (limited privileges: SELECT, INSERT, UPDATE, DELETE) - // Credentials provided via pgpass file - User string `yaml:"user"` - - // MigrationUser is the migration user (elevated privileges: CREATE, ALTER, DROP) - // Used for running database schema migrations - // Credentials provided via pgpass file - MigrationUser string `yaml:"migrationUser"` - - // Database is the database name - Database string `yaml:"database"` - - // SSLMode is the SSL mode for the connection - SSLMode string `yaml:"sslMode"` - - // MaxOpenConns is the maximum number of open connections to the database - MaxOpenConns int32 `yaml:"maxOpenConns"` - - // MaxIdleConns is the maximum number of idle connections in the pool - MaxIdleConns int32 `yaml:"maxIdleConns"` - - // ConnMaxLifetime is the maximum amount of time a connection may be reused - ConnMaxLifetime string `yaml:"connMaxLifetime"` - - // MaxMetaSize is the maximum allowed size in bytes for publisher-provided metadata extensions - MaxMetaSize *int32 `yaml:"maxMetaSize,omitempty"` - - // DynamicAuth defines dynamic database authentication configuration - DynamicAuth *DynamicAuthConfig `yaml:"dynamicAuth,omitempty"` -} - -// DynamicAuthConfig defines dynamic database authentication configuration -type DynamicAuthConfig struct { - AWSRDSIAM *DynamicAuthAWSRDSIAM `yaml:"awsRdsIam,omitempty"` -} - -// DynamicAuthAWSRDSIAM defines AWS RDS IAM authentication configuration -type DynamicAuthAWSRDSIAM struct { - Region string `yaml:"region,omitempty"` -} - -// SourceConfig defines a single data source configuration (v2 format) -type SourceConfig struct { - Name string `yaml:"name"` - Format string `yaml:"format,omitempty"` - Claims map[string]any `yaml:"claims,omitempty"` - Git *GitConfig `yaml:"git,omitempty"` - API *APIConfig `yaml:"api,omitempty"` - File *FileConfig `yaml:"file,omitempty"` - Managed *ManagedConfig `yaml:"managed,omitempty"` - Kubernetes *KubernetesConfig `yaml:"kubernetes,omitempty"` - SyncPolicy *SyncPolicyConfig `yaml:"syncPolicy,omitempty"` - Filter *FilterConfig `yaml:"filter,omitempty"` -} - -// RegistryConfig defines a lightweight registry view that aggregates sources (v2 format) -type RegistryConfig struct { - Name string `yaml:"name"` - Sources []string `yaml:"sources"` - Claims map[string]any `yaml:"claims,omitempty"` -} - -// ManagedConfig defines configuration for managed sources. -// Managed sources are directly manipulated via API and do not sync from external sources. -type ManagedConfig struct{} - -// TelemetryConfig defines OpenTelemetry configuration -type TelemetryConfig struct { - Enabled bool `yaml:"enabled"` - ServiceName string `yaml:"serviceName,omitempty"` - ServiceVersion string `yaml:"serviceVersion,omitempty"` - Endpoint string `yaml:"endpoint,omitempty"` - Insecure bool `yaml:"insecure,omitempty"` - Tracing *TracingConfig `yaml:"tracing,omitempty"` - Metrics *MetricsConfig `yaml:"metrics,omitempty"` -} - -// TracingConfig defines tracing-specific configuration -type TracingConfig struct { - Enabled bool `yaml:"enabled"` - Sampling *float64 `yaml:"sampling,omitempty"` -} - -// MetricsConfig defines metrics-specific configuration -type MetricsConfig struct { - Enabled bool `yaml:"enabled"` -} - -// AuthMode represents the authentication mode -type AuthMode string - -const ( - // AuthModeAnonymous allows unauthenticated access - AuthModeAnonymous AuthMode = "anonymous" - - // AuthModeOAuth enables OAuth/OIDC authentication - AuthModeOAuth AuthMode = "oauth" -) - -// AuthConfig defines authentication configuration for the registry server -type AuthConfig struct { - // Mode specifies the authentication mode (anonymous or oauth) - // Defaults to "oauth" if not specified (security-by-default). - // Use "anonymous" to explicitly disable authentication for development. - Mode AuthMode `yaml:"mode,omitempty"` - - // PublicPaths defines additional paths that bypass authentication - PublicPaths []string `yaml:"publicPaths,omitempty"` - - // OAuth defines OAuth/OIDC specific authentication settings - // Only used when Mode is "oauth" - OAuth *OAuthConfig `yaml:"oauth,omitempty"` - - // Authz defines authorization configuration for role-based access control - Authz *AuthzConfig `yaml:"authz,omitempty"` -} - -// AuthzConfig defines authorization configuration for role-based access control -type AuthzConfig struct { - Roles RolesConfig `yaml:"roles,omitempty"` -} - -// RolesConfig defines role-based authorization rules -type RolesConfig struct { - SuperAdmin []map[string]any `yaml:"superAdmin,omitempty"` - ManageSources []map[string]any `yaml:"manageSources,omitempty"` - ManageRegistries []map[string]any `yaml:"manageRegistries,omitempty"` - ManageEntries []map[string]any `yaml:"manageEntries,omitempty"` -} - -// OAuthConfig defines OAuth/OIDC specific authentication settings -type OAuthConfig struct { - // ResourceURL is the URL identifying this protected resource (RFC 9728) - // Used in the /.well-known/oauth-protected-resource endpoint - ResourceURL string `yaml:"resourceUrl,omitempty"` - - // Providers defines the OAuth/OIDC providers for authentication - // Multiple providers can be configured (e.g., Kubernetes + external IDP) - Providers []OAuthProviderConfig `yaml:"providers,omitempty"` - - // ScopesSupported defines the OAuth scopes supported by this resource (RFC 9728) - // Defaults to ["mcp-registry:read", "mcp-registry:write"] if not specified - ScopesSupported []string `yaml:"scopesSupported,omitempty"` - - // Realm is the protection space identifier for WWW-Authenticate header (RFC 7235) - // Defaults to "mcp-registry" if not specified - Realm string `yaml:"realm,omitempty"` -} - -// OAuthProviderConfig defines configuration for an OAuth/OIDC provider -type OAuthProviderConfig struct { - // Name is a unique identifier for this provider (e.g., "kubernetes", "keycloak") - Name string `yaml:"name"` - - // IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com) - // The JWKS URL will be discovered automatically from .well-known/openid-configuration - // unless JwksUrl is explicitly specified - IssuerURL string `yaml:"issuerUrl"` - - // JwksUrl is the URL to fetch the JSON Web Key Set (JWKS) from - // If specified, OIDC discovery is skipped and this URL is used directly - // Example: https://kubernetes.default.svc/openid/v1/jwks - JwksUrl string `yaml:"jwksUrl,omitempty"` - - // Audience is the expected audience claim in the token (REQUIRED) - // Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience - // For Kubernetes, this is typically the API server URL - Audience string `yaml:"audience"` - - // ClientID is the OAuth client ID for token introspection (optional) - ClientID string `yaml:"clientId,omitempty"` - - // ClientSecretFile is the path to a file containing the client secret - // The file should contain only the secret with optional trailing whitespace - ClientSecretFile string `yaml:"clientSecretFile,omitempty"` - - // CACertPath is the path to a CA certificate bundle for verifying the provider's TLS certificate - // Required for Kubernetes in-cluster authentication or self-signed certificates - CACertPath string `yaml:"caCertPath,omitempty"` - - // AuthTokenFile is the path to a file containing a bearer token for authenticating to OIDC/JWKS endpoints - // Useful when the OIDC discovery or JWKS endpoint requires authentication - // Example: /var/run/secrets/kubernetes.io/serviceaccount/token - AuthTokenFile string `yaml:"authTokenFile,omitempty"` - - // IntrospectionURL is the OAuth 2.0 Token Introspection endpoint (RFC 7662) - // Used for validating opaque (non-JWT) tokens - // If not specified, only JWT tokens can be validated via JWKS - IntrospectionURL string `yaml:"introspectionUrl,omitempty"` - - // AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - // Required when the OAuth provider (e.g., Kubernetes API server) is running on a private network - // Example: Set to true when using https://kubernetes.default.svc as the issuer URL - AllowPrivateIP bool `yaml:"allowPrivateIP,omitempty"` -} - -// KubernetesConfig defines a Kubernetes-based source where data is discovered -// from MCPServer resources in the cluster. -type KubernetesConfig struct { - // Namespaces is a list of Kubernetes namespaces to watch for MCP servers. - // If empty, watches the operator's configured namespace. - Namespaces []string `yaml:"namespaces,omitempty"` -} - -// GitConfig defines Git source settings -type GitConfig struct { - // Repository is the Git repository URL (HTTP/HTTPS/SSH) - Repository string `yaml:"repository"` - - // Branch is the Git branch to use (mutually exclusive with Tag and Commit) - Branch string `yaml:"branch,omitempty"` - - // Tag is the Git tag to use (mutually exclusive with Branch and Commit) - Tag string `yaml:"tag,omitempty"` - - // Commit is the Git commit SHA to use (mutually exclusive with Branch and Tag) - Commit string `yaml:"commit,omitempty"` - - // Path is the path to the registry file within the repository - Path string `yaml:"path,omitempty"` - - // Auth contains optional authentication for private repositories - Auth *GitAuthConfig `yaml:"auth,omitempty"` -} - -// GitAuthConfig defines authentication settings for Git repositories -type GitAuthConfig struct { - // Username is the Git username for HTTP Basic authentication - Username string `yaml:"username,omitempty"` - - // PasswordFile is the path to a file containing the Git password/token - PasswordFile string `yaml:"passwordFile,omitempty"` -} - -// APIConfig defines API source configuration for ToolHive Registry APIs -type APIConfig struct { - // Endpoint is the base API URL (without path) - // The source handler will append the appropriate paths, for instance: - // - /v0/servers - List all servers (single response, no pagination) - // - /v0/servers/{name} - Get specific server (future) - // - /v0/info - Get registry metadata (future) - // Example: "http://my-registry-api.default.svc.cluster.local/api" - Endpoint string `yaml:"endpoint"` -} - -// FileConfig defines file source configuration -type FileConfig struct { - // Path is the path to the registry.json file on the local filesystem - // Can be absolute or relative to the working directory - Path string `yaml:"path,omitempty"` - - // URL is the HTTP/HTTPS URL to fetch the registry file from - URL string `yaml:"url,omitempty"` - - // Data is the inline registry data as a JSON string - Data string `yaml:"data,omitempty"` - - // Timeout is the timeout for HTTP requests when using URL - Timeout string `yaml:"timeout,omitempty"` -} - -// SyncPolicyConfig defines synchronization settings -type SyncPolicyConfig struct { - Interval string `yaml:"interval"` -} - -// FilterConfig defines filtering rules for registry entries -type FilterConfig struct { - Names *NameFilterConfig `yaml:"names,omitempty"` - Tags *TagFilterConfig `yaml:"tags,omitempty"` -} - -// NameFilterConfig defines name-based filtering -type NameFilterConfig struct { - Include []string `yaml:"include,omitempty"` - Exclude []string `yaml:"exclude,omitempty"` -} - -// TagFilterConfig defines tag-based filtering -type TagFilterConfig struct { - Include []string `yaml:"include,omitempty"` - Exclude []string `yaml:"exclude,omitempty"` -} - -// ToConfigMapWithContentChecksum converts the Config to a ConfigMap with a content checksum annotation -func (c *Config) ToConfigMapWithContentChecksum(mcpRegistry *mcpv1alpha1.MCPRegistry) (*corev1.ConfigMap, error) { - yamlData, err := yaml.Marshal(c) - if err != nil { - return nil, fmt.Errorf("failed to marshal config to YAML: %w", err) - } - - configMap := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-registry-server-config", mcpRegistry.Name), - Namespace: mcpRegistry.Namespace, - Annotations: map[string]string{ - checksum.ContentChecksumAnnotation: ctrlutil.CalculateConfigHash(yamlData), - }, - }, - Data: map[string]string{ - RegistryServerConfigFileName: string(yamlData), - }, - } - return configMap, nil -} - -func (cm *configManager) BuildConfig() (*Config, error) { - config := Config{} - - mcpRegistry := cm.mcpRegistry - - if mcpRegistry.Name == "" { - return nil, fmt.Errorf("registry name is required") - } - - if len(mcpRegistry.Spec.Sources) == 0 { - return nil, fmt.Errorf("at least one source must be specified") - } - - if len(mcpRegistry.Spec.Registries) == 0 { - return nil, fmt.Errorf("at least one registry must be specified") - } - - // Validate source names are unique - if err := validateSourceNames(mcpRegistry.Spec.Sources); err != nil { - return nil, fmt.Errorf("invalid source configuration: %w", err) - } - - // Validate registry names are unique - if err := validateRegistryViewNames(mcpRegistry.Spec.Registries); err != nil { - return nil, fmt.Errorf("invalid registry configuration: %w", err) - } - - // Build source configs - sources := make([]SourceConfig, 0, len(mcpRegistry.Spec.Sources)) - for _, sourceSpec := range mcpRegistry.Spec.Sources { - sourceConfig, err := buildSourceConfig(&sourceSpec) - if err != nil { - return nil, fmt.Errorf("failed to build source configuration for %q: %w", sourceSpec.Name, err) - } - sources = append(sources, *sourceConfig) - } - config.Sources = sources - - // Build source name set for validation - sourceNames := make(map[string]bool, len(config.Sources)) - for _, s := range config.Sources { - sourceNames[s.Name] = true - } - - // Build registry view configs - registries := make([]RegistryConfig, 0, len(mcpRegistry.Spec.Registries)) - for _, regSpec := range mcpRegistry.Spec.Registries { - regConfig, err := buildRegistryViewConfig(®Spec, sourceNames) - if err != nil { - return nil, fmt.Errorf("failed to build registry configuration for %q: %w", regSpec.Name, err) - } - registries = append(registries, *regConfig) - } - config.Registries = registries - - // Build database configuration from CRD spec or use defaults - //nolint:staticcheck // Legacy path intentionally uses deprecated field - config.Database = buildDatabaseConfig(mcpRegistry.Spec.DatabaseConfig) - - // Build authentication configuration from CRD spec or use defaults - //nolint:staticcheck // Legacy path intentionally uses deprecated field - authConfig, err := buildAuthConfig(mcpRegistry.Spec.AuthConfig) - if err != nil { - return nil, fmt.Errorf("failed to build authentication configuration: %w", err) - } - config.Auth = authConfig - - // Build telemetry configuration from CRD spec - telemetryConfig, err := buildTelemetryConfig(mcpRegistry.Spec.TelemetryConfig) - if err != nil { - return nil, fmt.Errorf("failed to build telemetry configuration: %w", err) - } - config.Telemetry = telemetryConfig - - return &config, nil -} - -// validateSourceNames ensures all source names are unique -func validateSourceNames(sources []mcpv1alpha1.MCPRegistrySourceConfig) error { - seen := make(map[string]bool) - for _, source := range sources { - if source.Name == "" { - return fmt.Errorf("source name is required") - } - if seen[source.Name] { - return fmt.Errorf("duplicate source name: %q", source.Name) - } - seen[source.Name] = true - } - return nil -} - -// validateRegistryViewNames ensures all registry view names are unique -func validateRegistryViewNames(registries []mcpv1alpha1.MCPRegistryViewConfig) error { - seen := make(map[string]bool) - for _, reg := range registries { - if reg.Name == "" { - return fmt.Errorf("registry name is required") - } - if seen[reg.Name] { - return fmt.Errorf("duplicate registry name: %q", reg.Name) - } - seen[reg.Name] = true - } - return nil -} - -func buildFilePath(sourceName string) *FileConfig { - return &FileConfig{ - Path: filepath.Join(RegistryJSONFilePath, sourceName, RegistryJSONFileName), - } -} - -//nolint:gocyclo // Complexity is acceptable for handling multiple source types -func buildSourceConfig(sourceSpec *mcpv1alpha1.MCPRegistrySourceConfig) (*SourceConfig, error) { - if sourceSpec.Name == "" { - return nil, fmt.Errorf("source name is required") - } - - sourceConfig := SourceConfig{ - Name: sourceSpec.Name, - Format: sourceSpec.Format, - } - - if sourceSpec.Format == "" { - sourceConfig.Format = mcpv1alpha1.RegistryFormatToolHive - } - - // Deserialize claims if present - if sourceSpec.Claims != nil { - claims, err := deserializeClaims(sourceSpec.Claims) - if err != nil { - return nil, fmt.Errorf("invalid claims: %w", err) - } - sourceConfig.Claims = claims - } - - // Determine source type and build appropriate config - sourceCount := 0 - if sourceSpec.ConfigMapRef != nil { - sourceCount++ - sourceConfig.File = buildFilePath(sourceSpec.Name) - } - if sourceSpec.Git != nil { - sourceCount++ - gitConfig, err := buildGitSourceConfig(sourceSpec.Git) - if err != nil { - return nil, fmt.Errorf("failed to build Git source configuration: %w", err) - } - sourceConfig.Git = gitConfig - } - if sourceSpec.API != nil { - sourceCount++ - apiConfig, err := buildAPISourceConfig(sourceSpec.API) - if err != nil { - return nil, fmt.Errorf("failed to build API source configuration: %w", err) - } - sourceConfig.API = apiConfig - } - if sourceSpec.URL != nil { - sourceCount++ - sourceConfig.File = &FileConfig{ - URL: sourceSpec.URL.Endpoint, - Timeout: sourceSpec.URL.Timeout, - } - } - if sourceSpec.Managed != nil { - sourceCount++ - sourceConfig.Managed = &ManagedConfig{} - } - if sourceSpec.Kubernetes != nil { - sourceCount++ - sourceConfig.Kubernetes = &KubernetesConfig{ - Namespaces: sourceSpec.Kubernetes.Namespaces, - } - } - - if sourceCount == 0 { - return nil, fmt.Errorf( - "exactly one source type must be specified") - } - if sourceCount > 1 { - return nil, fmt.Errorf( - "only one source type can be specified") - } - - // Build sync policy (not applicable for managed/kubernetes sources) - if sourceSpec.SyncPolicy != nil && sourceSpec.Managed == nil && sourceSpec.Kubernetes == nil { - if sourceSpec.SyncPolicy.Interval == "" { - return nil, fmt.Errorf("sync policy interval is required") - } - sourceConfig.SyncPolicy = &SyncPolicyConfig{ - Interval: sourceSpec.SyncPolicy.Interval, - } - } - - // Build filter (not applicable for managed/kubernetes sources) - if sourceSpec.Filter != nil && sourceSpec.Managed == nil && sourceSpec.Kubernetes == nil { - filterConfig := &FilterConfig{} - if sourceSpec.Filter.NameFilters != nil { - filterConfig.Names = &NameFilterConfig{ - Include: sourceSpec.Filter.NameFilters.Include, - Exclude: sourceSpec.Filter.NameFilters.Exclude, - } - } - if sourceSpec.Filter.Tags != nil { - filterConfig.Tags = &TagFilterConfig{ - Include: sourceSpec.Filter.Tags.Include, - Exclude: sourceSpec.Filter.Tags.Exclude, - } - } - sourceConfig.Filter = filterConfig - } - - return &sourceConfig, nil -} - -// buildRegistryViewConfig builds a RegistryConfig from a CRD MCPRegistryViewConfig -func buildRegistryViewConfig( - regSpec *mcpv1alpha1.MCPRegistryViewConfig, - validSourceNames map[string]bool, -) (*RegistryConfig, error) { - if regSpec.Name == "" { - return nil, fmt.Errorf("registry name is required") - } - - if len(regSpec.Sources) == 0 { - return nil, fmt.Errorf("at least one source reference is required") - } - - // Validate all source references exist - for _, srcName := range regSpec.Sources { - if !validSourceNames[srcName] { - return nil, fmt.Errorf("registry %q references unknown source %q", regSpec.Name, srcName) - } - } - - regConfig := &RegistryConfig{ - Name: regSpec.Name, - Sources: regSpec.Sources, - } - - // Deserialize claims if present - if regSpec.Claims != nil { - claims, err := deserializeClaims(regSpec.Claims) - if err != nil { - return nil, fmt.Errorf("invalid claims: %w", err) - } - regConfig.Claims = claims - } - - return regConfig, nil -} - -// deserializeClaims converts apiextensionsv1.JSON to map[string]any and validates -// that all values are string or []string. -func deserializeClaims(raw *apiextensionsv1.JSON) (map[string]any, error) { - if raw == nil || raw.Raw == nil { - return nil, nil - } - - var claims map[string]any - if err := json.Unmarshal(raw.Raw, &claims); err != nil { - return nil, fmt.Errorf("claims must be a JSON object: %w", err) - } - - // Validate that values are string or []string - for key, val := range claims { - switch v := val.(type) { - case string: - // OK - case []any: - // Convert to []string and validate - strs := make([]string, 0, len(v)) - for _, item := range v { - s, ok := item.(string) - if !ok { - return nil, fmt.Errorf("claim %q: array values must be strings, got %T", key, item) - } - strs = append(strs, s) - } - claims[key] = strs - default: - return nil, fmt.Errorf("claim %q: value must be string or []string, got %T", key, val) - } - } - - return claims, nil -} - -// deserializeRoleEntry converts apiextensionsv1.JSON to map[string]any for a role entry. -// Values are validated to be string or []string, matching the claims validation contract. -func deserializeRoleEntry(raw apiextensionsv1.JSON) (map[string]any, error) { - if raw.Raw == nil { - return nil, nil - } - - var entry map[string]any - if err := json.Unmarshal(raw.Raw, &entry); err != nil { - return nil, fmt.Errorf("role entry must be a JSON object: %w", err) - } - - for key, val := range entry { - switch v := val.(type) { - case string: - // OK - case []any: - strs := make([]string, 0, len(v)) - for _, item := range v { - s, ok := item.(string) - if !ok { - return nil, fmt.Errorf("role entry %q: array values must be strings, got %T", key, item) - } - strs = append(strs, s) - } - entry[key] = strs - default: - return nil, fmt.Errorf("role entry %q: value must be string or []string, got %T", key, val) - } - } - - return entry, nil -} - -func buildGitSourceConfig(git *mcpv1alpha1.GitSource) (*GitConfig, error) { - if git == nil { - return nil, fmt.Errorf("git source configuration is required") - } - - if git.Repository == "" { - return nil, fmt.Errorf("git repository is required") - } - - if git.Path == "" { - return nil, fmt.Errorf("git path is required") - } - - serverGitConfig := GitConfig{ - Repository: git.Repository, - Path: git.Path, - } - - switch { - case git.Branch != "": - serverGitConfig.Branch = git.Branch - case git.Tag != "": - serverGitConfig.Tag = git.Tag - case git.Commit != "": - serverGitConfig.Commit = git.Commit - default: - return nil, fmt.Errorf("git branch, tag, and commit are mutually exclusive, please provide only one of them") - } - - // Build auth config if specified - if git.Auth != nil { - authConfig, err := buildGitAuthConfig(git.Auth) - if err != nil { - return nil, fmt.Errorf("failed to build git auth configuration: %w", err) - } - serverGitConfig.Auth = authConfig - } - - return &serverGitConfig, nil -} - -// buildGitAuthConfig creates a GitAuthConfig from the CRD spec. -// It validates that required fields are present and constructs the password file path. -func buildGitAuthConfig(auth *mcpv1alpha1.GitAuthConfig) (*GitAuthConfig, error) { - if auth == nil { - return nil, nil - } - - if auth.Username == "" { - return nil, fmt.Errorf("git auth username is required") - } - - if auth.PasswordSecretRef.Name == "" { - return nil, fmt.Errorf("git auth password secret reference name is required") - } - - if auth.PasswordSecretRef.Key == "" { - return nil, fmt.Errorf("git auth password secret reference key is required") - } - - return &GitAuthConfig{ - Username: auth.Username, - PasswordFile: buildGitPasswordFilePath(&auth.PasswordSecretRef), - }, nil -} - -// buildGitPasswordFilePath constructs the file path where a git password secret will be mounted. -// The secretRef must have both Name and Key set (validated by buildGitAuthConfig). -func buildGitPasswordFilePath(secretRef *corev1.SecretKeySelector) string { - if secretRef == nil { - return "" - } - return fmt.Sprintf("/secrets/%s/%s", secretRef.Name, secretRef.Key) -} - -func buildAPISourceConfig(api *mcpv1alpha1.APISource) (*APIConfig, error) { - if api == nil { - return nil, fmt.Errorf("api source configuration is required") - } - - if api.Endpoint == "" { - return nil, fmt.Errorf("api endpoint is required") - } - - return &APIConfig{ - Endpoint: api.Endpoint, - }, nil -} - -// buildDatabaseConfig creates a DatabaseConfig from the CRD spec. -// If the spec is nil or fields are empty, sensible defaults are used. -func buildDatabaseConfig(dbConfig *mcpv1alpha1.MCPRegistryDatabaseConfig) *DatabaseConfig { - // Default values - config := &DatabaseConfig{ - Host: "postgres", - Port: 5432, - User: "db_app", - MigrationUser: "db_migrator", - Database: "registry", - SSLMode: "prefer", - MaxOpenConns: 10, - MaxIdleConns: 2, - ConnMaxLifetime: "30m", - } - - // If no database config specified, return defaults - if dbConfig == nil { - return config - } - - // Override defaults with values from CRD spec if provided - if dbConfig.Host != "" { - config.Host = dbConfig.Host - } - if dbConfig.Port != 0 { - config.Port = dbConfig.Port - } - if dbConfig.User != "" { - config.User = dbConfig.User - } - if dbConfig.MigrationUser != "" { - config.MigrationUser = dbConfig.MigrationUser - } - if dbConfig.Database != "" { - config.Database = dbConfig.Database - } - if dbConfig.SSLMode != "" { - config.SSLMode = dbConfig.SSLMode - } - if dbConfig.MaxOpenConns != 0 { - config.MaxOpenConns = dbConfig.MaxOpenConns - } - if dbConfig.MaxIdleConns != 0 { - config.MaxIdleConns = dbConfig.MaxIdleConns - } - if dbConfig.ConnMaxLifetime != "" { - config.ConnMaxLifetime = dbConfig.ConnMaxLifetime - } - if dbConfig.MaxMetaSize != nil { - config.MaxMetaSize = dbConfig.MaxMetaSize - } - if dbConfig.DynamicAuth != nil && dbConfig.DynamicAuth.AWSRDSIAM != nil { - config.DynamicAuth = &DynamicAuthConfig{ - AWSRDSIAM: &DynamicAuthAWSRDSIAM{ - Region: dbConfig.DynamicAuth.AWSRDSIAM.Region, - }, - } - } - - return config -} - -// buildAuthConfig creates an AuthConfig from the CRD spec. -// If the spec is nil, defaults to anonymous authentication. -func buildAuthConfig( - authConfig *mcpv1alpha1.MCPRegistryAuthConfig, -) (*AuthConfig, error) { - config := &AuthConfig{} - if authConfig == nil { - // Note: we default to anonymous for backwards compatibility and - // because we don't have active production deployments yet. - // The plan is to remove this default and require the user to specify - // the mode. - // TODO: Remove this default once testing is complete. - config.Mode = AuthModeAnonymous - return config, nil - } - - // Map the mode from CRD type to config type - switch authConfig.Mode { - case mcpv1alpha1.MCPRegistryAuthModeOAuth: - config.Mode = AuthModeOAuth - case mcpv1alpha1.MCPRegistryAuthModeAnonymous: - config.Mode = AuthModeAnonymous - default: - // Default to anonymous if mode is empty or unrecognized - config.Mode = AuthModeAnonymous - } - - // Map public paths - if len(authConfig.PublicPaths) > 0 { - config.PublicPaths = authConfig.PublicPaths - } - - // Build OAuth config if mode is oauth and OAuth config is provided - if config.Mode == AuthModeOAuth && authConfig.OAuth != nil { - oauthConfig, err := buildOAuthConfig(authConfig.OAuth) - if err != nil { - return nil, fmt.Errorf("failed to build OAuth configuration: %w", err) - } - config.OAuth = oauthConfig - } - - // Build authz config if provided - if authConfig.Authz != nil { - authzConfig, err := buildAuthzConfig(authConfig.Authz) - if err != nil { - return nil, fmt.Errorf("failed to build authorization configuration: %w", err) - } - config.Authz = authzConfig - } - - return config, nil -} - -// buildAuthzConfig creates an AuthzConfig from the CRD spec. -func buildAuthzConfig(authzConfig *mcpv1alpha1.MCPRegistryAuthzConfig) (*AuthzConfig, error) { - if authzConfig == nil { - return nil, nil - } - - config := &AuthzConfig{} - - roles := &authzConfig.Roles - - var err error - config.Roles.SuperAdmin, err = deserializeRoleEntries(roles.SuperAdmin) - if err != nil { - return nil, fmt.Errorf("invalid superAdmin roles: %w", err) - } - - config.Roles.ManageSources, err = deserializeRoleEntries(roles.ManageSources) - if err != nil { - return nil, fmt.Errorf("invalid manageSources roles: %w", err) - } - - config.Roles.ManageRegistries, err = deserializeRoleEntries(roles.ManageRegistries) - if err != nil { - return nil, fmt.Errorf("invalid manageRegistries roles: %w", err) - } - - config.Roles.ManageEntries, err = deserializeRoleEntries(roles.ManageEntries) - if err != nil { - return nil, fmt.Errorf("invalid manageEntries roles: %w", err) - } - - return config, nil -} - -// deserializeRoleEntries converts a slice of apiextensionsv1.JSON to []map[string]any -func deserializeRoleEntries(entries []apiextensionsv1.JSON) ([]map[string]any, error) { - if len(entries) == 0 { - return nil, nil - } - - result := make([]map[string]any, 0, len(entries)) - for i, entry := range entries { - m, err := deserializeRoleEntry(entry) - if err != nil { - return nil, fmt.Errorf("entry %d: %w", i, err) - } - if m != nil { - result = append(result, m) - } - } - - return result, nil -} - -// buildOAuthConfig creates an OAuthConfig from the CRD spec. -func buildOAuthConfig( - oauthConfig *mcpv1alpha1.MCPRegistryOAuthConfig, -) (*OAuthConfig, error) { - if oauthConfig == nil { - return nil, fmt.Errorf("OAuth configuration is required") - } - - // TODO: Uncomment this after testing intra-cluster authentication - if len(oauthConfig.Providers) == 0 { - return nil, fmt.Errorf("at least one OAuth provider is required") - } - - config := &OAuthConfig{ - ResourceURL: oauthConfig.ResourceURL, - ScopesSupported: oauthConfig.ScopesSupported, - Realm: oauthConfig.Realm, - Providers: make([]OAuthProviderConfig, 0, len(oauthConfig.Providers)), - } - - // Build provider configs - for _, providerSpec := range oauthConfig.Providers { - provider, err := buildOAuthProviderConfig(&providerSpec) - if err != nil { - return nil, fmt.Errorf("failed to build OAuth provider configuration: %w", err) - } - config.Providers = append(config.Providers, *provider) - } - - return config, nil -} - -// buildOAuthProviderConfig creates an OAuthProviderConfig from the CRD spec. -func buildOAuthProviderConfig( - providerSpec *mcpv1alpha1.MCPRegistryOAuthProviderConfig, -) (*OAuthProviderConfig, error) { - if providerSpec == nil { - return nil, fmt.Errorf("provider specification is required") - } - - if providerSpec.Name == "" { - return nil, fmt.Errorf("provider name is required") - } - if providerSpec.IssuerURL == "" { - return nil, fmt.Errorf("provider issuer URL is required") - } - if providerSpec.Audience == "" { - return nil, fmt.Errorf("provider audience is required") - } - - config := &OAuthProviderConfig{ - Name: providerSpec.Name, - IssuerURL: providerSpec.IssuerURL, - Audience: providerSpec.Audience, - AllowPrivateIP: providerSpec.AllowPrivateIP, - } - - // JwksUrl is optional - if specified, OIDC discovery is skipped - if providerSpec.JwksUrl != "" { - config.JwksUrl = providerSpec.JwksUrl - } - - // ClientID is optional, so we only set it if provided - if providerSpec.ClientID != "" { - config.ClientID = providerSpec.ClientID - } - - // IntrospectionURL is optional - used for validating opaque tokens - if providerSpec.IntrospectionURL != "" { - config.IntrospectionURL = providerSpec.IntrospectionURL - } - - // For ClientSecretRef, CACertRef, and AuthTokenRef, we store the path where the secret/configmap - // will be mounted. The actual mounting is handled by the pod template spec builder. - // The registry server will read the file at runtime. - if providerSpec.ClientSecretRef != nil { - // Secret will be mounted at /secrets/{secretName}/{key} - config.ClientSecretFile = buildSecretFilePath(providerSpec.ClientSecretRef) - } - - // CaCertPath can be set directly or via CACertRef - // Direct path takes precedence over reference - if providerSpec.CaCertPath != "" { - config.CACertPath = providerSpec.CaCertPath - } else if providerSpec.CACertRef != nil { - // ConfigMap will be mounted at /config/certs/{configMapName}/{key} - config.CACertPath = buildCACertFilePath(providerSpec.CACertRef) - } - - // AuthTokenFile can be set directly or via AuthTokenRef - // Direct path takes precedence over reference - if providerSpec.AuthTokenFile != "" { - config.AuthTokenFile = providerSpec.AuthTokenFile - } else if providerSpec.AuthTokenRef != nil { - // Secret will be mounted at /secrets/{secretName}/{key} - config.AuthTokenFile = buildSecretFilePath(providerSpec.AuthTokenRef) - } - - return config, nil -} - -// buildSecretFilePath constructs the file path where a secret will be mounted -func buildSecretFilePath(secretRef *corev1.SecretKeySelector) string { - if secretRef == nil { - return "" - } - key := secretRef.Key - if key == "" { - key = "clientSecret" - } - return fmt.Sprintf("/secrets/%s/%s", secretRef.Name, key) -} - -// buildTelemetryConfig creates a TelemetryConfig from the CRD spec. -// Returns nil if the spec is nil (telemetry disabled). -func buildTelemetryConfig(telConfig *mcpv1alpha1.MCPRegistryTelemetryConfig) (*TelemetryConfig, error) { - if telConfig == nil { - return nil, nil - } - - config := &TelemetryConfig{ - Enabled: telConfig.Enabled, - ServiceName: telConfig.ServiceName, - ServiceVersion: telConfig.ServiceVersion, - Endpoint: telConfig.Endpoint, - Insecure: telConfig.Insecure, - } - - if telConfig.Tracing != nil { - tracingConfig := &TracingConfig{ - Enabled: telConfig.Tracing.Enabled, - } - if telConfig.Tracing.Sampling != nil { - val, err := strconv.ParseFloat(*telConfig.Tracing.Sampling, 64) - if err != nil { - return nil, fmt.Errorf("invalid tracing sampling value %q: %w", *telConfig.Tracing.Sampling, err) - } - tracingConfig.Sampling = &val - } - config.Tracing = tracingConfig - } - - if telConfig.Metrics != nil { - config.Metrics = &MetricsConfig{ - Enabled: telConfig.Metrics.Enabled, - } - } - - return config, nil -} - -// buildCACertFilePath constructs the file path where a CA cert configmap will be mounted -func buildCACertFilePath(configMapRef *corev1.ConfigMapKeySelector) string { - if configMapRef == nil { - return "" - } - key := configMapRef.Key - if key == "" { - key = "ca.crt" - } - return fmt.Sprintf("/config/certs/%s/%s", configMapRef.Name, key) -} diff --git a/cmd/thv-operator/pkg/registryapi/config/config_test.go b/cmd/thv-operator/pkg/registryapi/config/config_test.go deleted file mode 100644 index ae08b71f4f..0000000000 --- a/cmd/thv-operator/pkg/registryapi/config/config_test.go +++ /dev/null @@ -1,2172 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package config - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" -) - -// helper to build a minimal valid MCPRegistry with one source and one registry view referencing it. -func minimalRegistry(sources []mcpv1alpha1.MCPRegistrySourceConfig, views []mcpv1alpha1.MCPRegistryViewConfig) *mcpv1alpha1.MCPRegistry { - return &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - }, - } -} - -func TestBuildConfig_EmptyRegistryName(t *testing.T) { - t.Parallel() - // Test that an empty registry name returns the correct error - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "", // Empty name should cause an error - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Equal(t, "registry name is required", err.Error()) - assert.Nil(t, config) -} - -func TestBuildConfig_NoRegistries(t *testing.T) { - t.Parallel() - // Test that empty registries array returns the correct error - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "some-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{}, // Empty registries should cause an error - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "at least one registry must be specified") - assert.Nil(t, config) -} - -func TestBuildConfig_MissingSource(t *testing.T) { - t.Parallel() - // Test that a source with no source type returns the correct error - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - // No source type specified - should cause an error - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "exactly one source type") - assert.Nil(t, config) -} - -func TestBuildConfig_EmptySourceNameInConfig(t *testing.T) { - t.Parallel() - // Test that an empty source name in config returns the correct error - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "", // Empty name should cause an error - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{""}, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "source name is required") - assert.Nil(t, config) -} - -func TestBuildConfig_DuplicateSourceNames(t *testing.T) { - t.Parallel() - // Test that duplicate source names return the correct error - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "duplicate", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - { - Name: "duplicate", // Duplicate name should cause an error - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap2", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"duplicate"}, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "duplicate source name") - assert.Nil(t, config) -} - -func TestBuildConfig_ConfigMapSource(t *testing.T) { - t.Parallel() - // Test suite for ConfigMap source validation - - t.Run("valid configmap source", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "configmap-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "registry-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "configmap-registry", - Sources: []string{"configmap-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should be the user-specified one - assert.Equal(t, "configmap-source", config.Sources[0].Name) - assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Sources[0].Format) - require.NotNil(t, config.Sources[0].File) - assert.Equal(t, filepath.Join(RegistryJSONFilePath, "configmap-source", RegistryJSONFileName), config.Sources[0].File.Path) - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "1h", config.Sources[0].SyncPolicy.Interval) - // Verify registry view - require.Len(t, config.Registries, 1) - assert.Equal(t, "configmap-registry", config.Registries[0].Name) - assert.Equal(t, []string{"configmap-source"}, config.Registries[0].Sources) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - -} - -func TestBuildConfig_GitSource(t *testing.T) { - t.Parallel() - // Test suite for Git source validation - - t.Run("nil git source object", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{}, // Empty Git source should cause an error - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "git repository is required") - assert.Nil(t, config) - }) - - t.Run("empty git repository", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "", // Empty repository should cause an error - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "git repository is required") - assert.Nil(t, config) - }) - - t.Run("no git reference specified", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/repo.git", - Path: "registry.json", - // No branch, tag, or commit specified - should cause an error - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "git branch, tag, and commit are mutually exclusive") - assert.Nil(t, config) - }) - - t.Run("no git path specified", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/repo.git", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "path is required") - assert.Nil(t, config) - }) - - t.Run("valid git source with branch", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "git-branch-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/repo.git", - Branch: "main", - Path: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "git-branch-registry", - Sources: []string{"git-branch-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should be the user-specified git source - assert.Equal(t, "git-branch-source", config.Sources[0].Name) - assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Sources[0].Format) - require.NotNil(t, config.Sources[0].Git) - assert.Equal(t, "https://github.com/example/repo.git", config.Sources[0].Git.Repository) - assert.Equal(t, "main", config.Sources[0].Git.Branch) - assert.Empty(t, config.Sources[0].Git.Tag) - assert.Empty(t, config.Sources[0].Git.Commit) - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "1h", config.Sources[0].SyncPolicy.Interval) - // Verify registry view - require.Len(t, config.Registries, 1) - assert.Equal(t, "git-branch-registry", config.Registries[0].Name) - assert.Equal(t, []string{"git-branch-source"}, config.Registries[0].Sources) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("valid git source with tag", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "git-tag-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "git@github.com:example/repo.git", - Tag: "v1.2.3", - Path: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "git-tag-registry", - Sources: []string{"git-tag-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should be the user-specified git source - assert.Equal(t, "git-tag-source", config.Sources[0].Name) - assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Sources[0].Format) - require.NotNil(t, config.Sources[0].Git) - assert.Equal(t, "git@github.com:example/repo.git", config.Sources[0].Git.Repository) - assert.Empty(t, config.Sources[0].Git.Branch) - assert.Equal(t, "v1.2.3", config.Sources[0].Git.Tag) - assert.Empty(t, config.Sources[0].Git.Commit) - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "1h", config.Sources[0].SyncPolicy.Interval) - // Verify registry view - require.Len(t, config.Registries, 1) - assert.Equal(t, "git-tag-registry", config.Registries[0].Name) - assert.Equal(t, []string{"git-tag-source"}, config.Registries[0].Sources) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("valid git source with commit", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "git-commit-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/repo.git", - Commit: "abc123def456", - Path: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "git-commit-registry", - Sources: []string{"git-commit-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should be the user-specified git source - assert.Equal(t, "git-commit-source", config.Sources[0].Name) - assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Sources[0].Format) - require.NotNil(t, config.Sources[0].Git) - assert.Equal(t, "https://github.com/example/repo.git", config.Sources[0].Git.Repository) - assert.Empty(t, config.Sources[0].Git.Branch) - assert.Empty(t, config.Sources[0].Git.Tag) - assert.Equal(t, "abc123def456", config.Sources[0].Git.Commit) - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "1h", config.Sources[0].SyncPolicy.Interval) - // Verify registry view - require.Len(t, config.Registries, 1) - assert.Equal(t, "git-commit-registry", config.Registries[0].Name) - assert.Equal(t, []string{"git-commit-source"}, config.Registries[0].Sources) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) -} - -func TestBuildConfig_GitAuth(t *testing.T) { - t.Parallel() - // Test suite for Git authentication configuration - - t.Run("valid git source with auth", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-git-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "git", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-credentials", - }, - Key: "token", - }, - }, - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "private-git-registry", - Sources: []string{"private-git-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should be the user-specified git source with auth - assert.Equal(t, "private-git-source", config.Sources[0].Name) - require.NotNil(t, config.Sources[0].Git) - require.NotNil(t, config.Sources[0].Git.Auth) - assert.Equal(t, "git", config.Sources[0].Git.Auth.Username) - assert.Equal(t, "/secrets/git-credentials/token", config.Sources[0].Git.Auth.PasswordFile) - }) - - t.Run("git auth missing password secret key", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-git-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "git", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-credentials", - }, - // Key is empty - should cause an error - }, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "private-git-registry", - Sources: []string{"private-git-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "git auth password secret reference key is required") - assert.Nil(t, config) - }) - - t.Run("git auth missing username", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-git-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - // Username is empty - should cause an error - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-credentials", - }, - Key: "token", - }, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "private-git-registry", - Sources: []string{"private-git-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "git auth username is required") - assert.Nil(t, config) - }) - - t.Run("git auth missing password secret name", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-git-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "git", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "", // Empty name should cause an error - }, - Key: "token", - }, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "private-git-registry", - Sources: []string{"private-git-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "git auth password secret reference name is required") - assert.Nil(t, config) - }) - -} - -func TestBuildConfig_APISource(t *testing.T) { - t.Parallel() - // Test suite for API source validation - - t.Run("nil api source object", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - API: &mcpv1alpha1.APISource{}, // Empty API source should cause an error - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "api endpoint is required") - assert.Nil(t, config) - }) - - t.Run("empty api endpoint", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - API: &mcpv1alpha1.APISource{ - Endpoint: "", // Empty endpoint should cause an error - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "api endpoint is required") - assert.Nil(t, config) - }) - - t.Run("valid api source with endpoint", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "api-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - API: &mcpv1alpha1.APISource{ - Endpoint: "https://api.example.com/registry", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "api-registry", - Sources: []string{"api-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should be the user-specified API source - assert.Equal(t, "api-source", config.Sources[0].Name) - assert.Equal(t, mcpv1alpha1.RegistryFormatToolHive, config.Sources[0].Format) - require.NotNil(t, config.Sources[0].API) - assert.Equal(t, "https://api.example.com/registry", config.Sources[0].API.Endpoint) - // Verify that other source types are nil - assert.Nil(t, config.Sources[0].File) - assert.Nil(t, config.Sources[0].Git) - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "1h", config.Sources[0].SyncPolicy.Interval) - // Verify registry view - require.Len(t, config.Registries, 1) - assert.Equal(t, "api-registry", config.Registries[0].Name) - assert.Equal(t, []string{"api-source"}, config.Registries[0].Sources) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) -} - -func TestBuildConfig_SyncPolicy(t *testing.T) { - t.Parallel() - // Test suite for SyncPolicy validation - - t.Run("nil sync policy", func(t *testing.T) { - t.Parallel() - // Nil sync policy is now optional (moved into source config) - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "sync-policy-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: nil, // Nil SyncPolicy is now optional - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "sync-policy-registry", - Sources: []string{"sync-policy-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should have nil sync policy - assert.Nil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("empty interval", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "", // Empty interval should cause an error - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default-view", - Sources: []string{"default"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "sync policy interval is required") - assert.Nil(t, config) - }) - - t.Run("valid sync policy with interval", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "sync-policy-valid-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "30m", // Valid interval - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "sync-policy-valid-registry", - Sources: []string{"sync-policy-valid-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should have valid sync policy - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "30m", config.Sources[0].SyncPolicy.Interval) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) -} - -func TestBuildConfig_Filter(t *testing.T) { - t.Parallel() - // Test suite for Filter validation - - t.Run("nil filter", func(t *testing.T) { - t.Parallel() - // Nil filter should not cause an error - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "filter-nil-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - Filter: nil, // Nil filter is optional - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "filter-nil-registry", - Sources: []string{"filter-nil-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // Filter should be nil when not provided for the user-specified source - assert.Nil(t, config.Sources[0].Filter) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("filter with name filters", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "filter-names-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - Filter: &mcpv1alpha1.RegistryFilter{ - NameFilters: &mcpv1alpha1.NameFilter{ - Include: []string{"server-*", "tool-*"}, - Exclude: []string{"*-deprecated", "*-test"}, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "filter-names-registry", - Sources: []string{"filter-names-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should have the filter - require.NotNil(t, config.Sources[0].Filter) - require.NotNil(t, config.Sources[0].Filter.Names) - assert.Equal(t, []string{"server-*", "tool-*"}, config.Sources[0].Filter.Names.Include) - assert.Equal(t, []string{"*-deprecated", "*-test"}, config.Sources[0].Filter.Names.Exclude) - // Tags should be nil when not provided - assert.Nil(t, config.Sources[0].Filter.Tags) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("filter with tags", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "filter-tags-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/repo.git", - Branch: "main", - Path: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "30m", - }, - Filter: &mcpv1alpha1.RegistryFilter{ - Tags: &mcpv1alpha1.TagFilter{ - Include: []string{"stable", "production", "v1.*"}, - Exclude: []string{"beta", "alpha", "experimental"}, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "filter-tags-registry", - Sources: []string{"filter-tags-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should have the filter - require.NotNil(t, config.Sources[0].Filter) - require.NotNil(t, config.Sources[0].Filter.Tags) - assert.Equal(t, []string{"stable", "production", "v1.*"}, config.Sources[0].Filter.Tags.Include) - assert.Equal(t, []string{"beta", "alpha", "experimental"}, config.Sources[0].Filter.Tags.Exclude) - // Names should be nil when not provided - assert.Nil(t, config.Sources[0].Filter.Names) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("filter with both name filters and tags", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "filter-both-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - API: &mcpv1alpha1.APISource{ - Endpoint: "https://api.example.com/registry", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "2h", - }, - Filter: &mcpv1alpha1.RegistryFilter{ - NameFilters: &mcpv1alpha1.NameFilter{ - Include: []string{"mcp-*"}, - Exclude: []string{"*-internal"}, - }, - Tags: &mcpv1alpha1.TagFilter{ - Include: []string{"latest", "stable"}, - Exclude: []string{"dev", "test"}, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "filter-both-registry", - Sources: []string{"filter-both-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should have the filter - require.NotNil(t, config.Sources[0].Filter) - // Both name filters and tags should be present - require.NotNil(t, config.Sources[0].Filter.Names) - assert.Equal(t, []string{"mcp-*"}, config.Sources[0].Filter.Names.Include) - assert.Equal(t, []string{"*-internal"}, config.Sources[0].Filter.Names.Exclude) - require.NotNil(t, config.Sources[0].Filter.Tags) - assert.Equal(t, []string{"latest", "stable"}, config.Sources[0].Filter.Tags.Include) - assert.Equal(t, []string{"dev", "test"}, config.Sources[0].Filter.Tags.Exclude) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("filter with empty include and exclude lists", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "filter-empty-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - Filter: &mcpv1alpha1.RegistryFilter{ - NameFilters: &mcpv1alpha1.NameFilter{ - Include: []string{}, - Exclude: []string{}, - }, - Tags: &mcpv1alpha1.TagFilter{ - Include: []string{}, - Exclude: []string{}, - }, - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "filter-empty-registry", - Sources: []string{"filter-empty-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 1 source: user-specified - require.Len(t, config.Sources, 1) - // First source should have the filter - require.NotNil(t, config.Sources[0].Filter) - // Empty lists should still be set - require.NotNil(t, config.Sources[0].Filter.Names) - assert.Empty(t, config.Sources[0].Filter.Names.Include) - assert.Empty(t, config.Sources[0].Filter.Names.Exclude) - require.NotNil(t, config.Sources[0].Filter.Tags) - assert.Empty(t, config.Sources[0].Filter.Tags.Include) - assert.Empty(t, config.Sources[0].Filter.Tags.Exclude) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) -} - -// TestToConfigMapWithContentChecksum tests that ToConfigMapWithContentChecksum creates -// a ConfigMap with the correct checksum annotation based on the YAML content. -func TestToConfigMapWithContentChecksum(t *testing.T) { - t.Parallel() - - // Create a populated Config object using the v2 format - config := &Config{ - Sources: []SourceConfig{ - { - Name: "default", - Format: "toolhive", - Git: &GitConfig{ - Repository: "https://github.com/example/mcp-servers.git", - Branch: "main", - Path: "registry.json", - }, - SyncPolicy: &SyncPolicyConfig{ - Interval: "15m", - }, - Filter: &FilterConfig{ - Names: &NameFilterConfig{ - Include: []string{"mcp-*"}, - Exclude: []string{"*-dev"}, - }, - }, - }, - }, - Registries: []RegistryConfig{ - { - Name: "checksum-test-registry", - Sources: []string{"default"}, - }, - }, - } - - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - }, - } - - // Call ToConfigMapWithContentChecksum - configMap, err := config.ToConfigMapWithContentChecksum(mcpRegistry) - - // Verify no error occurred - require.NoError(t, err) - require.NotNil(t, configMap) - - // Verify basic ConfigMap properties - assert.Equal(t, "test-registry-registry-server-config", configMap.Name) - assert.Equal(t, "test-namespace", configMap.Namespace) - - // Verify the checksum annotation exists - require.NotNil(t, configMap.Annotations) - checksumValue, exists := configMap.Annotations[checksum.ContentChecksumAnnotation] - require.True(t, exists, "Expected checksum annotation to exist") - require.NotEmpty(t, checksumValue, "Checksum should not be empty") - - // Verify the checksum format (should be a hex string) - // The actual checksum is calculated by ctrlutil.CalculateConfigHash - assert.Regexp(t, "^[a-f0-9]+$", checksumValue, "Checksum should be a hex string") - - // Verify the Data contains config.yaml - require.Contains(t, configMap.Data, "config.yaml") - yamlData := configMap.Data["config.yaml"] - require.NotEmpty(t, yamlData) - - // Verify YAML content includes expected fields - assert.Contains(t, yamlData, "repository: https://github.com/example/mcp-servers.git") - assert.Contains(t, yamlData, "interval: 15m") - - // Test that the same config produces the same checksum - configMap2, err := config.ToConfigMapWithContentChecksum(mcpRegistry) - require.NoError(t, err) - checksum2Value := configMap2.Annotations[checksum.ContentChecksumAnnotation] - assert.Equal(t, checksumValue, checksum2Value, "Same config should produce same checksum") - - // Test that different config produces different checksum - config.Sources[0].SyncPolicy.Interval = "30m" - configMap3, err := config.ToConfigMapWithContentChecksum(mcpRegistry) - require.NoError(t, err) - checksum3Value := configMap3.Annotations[checksum.ContentChecksumAnnotation] - assert.NotEqual(t, checksumValue, checksum3Value, "Different config should produce different checksum") -} - -func TestBuildConfig_MultipleSources(t *testing.T) { - t.Parallel() - // Test that multiple sources are properly built - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "source1", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "configmap1", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - { - Name: "source2", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/repo.git", - Branch: "main", - Path: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "30m", - }, - Filter: &mcpv1alpha1.RegistryFilter{ - NameFilters: &mcpv1alpha1.NameFilter{ - Include: []string{"server-*"}, - }, - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "registry1", - Sources: []string{"source1", "source2"}, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - // Should have 2 sources: user-specified - require.Len(t, config.Sources, 2) - - // Verify first source - assert.Equal(t, "source1", config.Sources[0].Name) - require.NotNil(t, config.Sources[0].File) - assert.Equal(t, filepath.Join(RegistryJSONFilePath, "source1", RegistryJSONFileName), config.Sources[0].File.Path) - require.NotNil(t, config.Sources[0].SyncPolicy) - assert.Equal(t, "1h", config.Sources[0].SyncPolicy.Interval) - assert.Nil(t, config.Sources[0].Filter) - - // Verify second source - assert.Equal(t, "source2", config.Sources[1].Name) - require.NotNil(t, config.Sources[1].Git) - assert.Equal(t, "https://github.com/example/repo.git", config.Sources[1].Git.Repository) - require.NotNil(t, config.Sources[1].SyncPolicy) - assert.Equal(t, "30m", config.Sources[1].SyncPolicy.Interval) - require.NotNil(t, config.Sources[1].Filter) - require.NotNil(t, config.Sources[1].Filter.Names) - assert.Equal(t, []string{"server-*"}, config.Sources[1].Filter.Names.Include) - - // Verify registry view - require.Len(t, config.Registries, 1) - assert.Equal(t, "registry1", config.Registries[0].Name) - assert.Equal(t, []string{"source1", "source2"}, config.Registries[0].Sources) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) -} - -func TestBuildConfig_DatabaseConfig(t *testing.T) { - t.Parallel() - - t.Run("default database config when nil", func(t *testing.T) { - t.Parallel() - mcpRegistry := minimalRegistry( - []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "db-nil-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "db-nil-registry", - Sources: []string{"db-nil-source"}, - }, - }, - ) - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Database) - - // Verify default values - assert.Equal(t, "postgres", config.Database.Host) - assert.Equal(t, int32(5432), config.Database.Port) - assert.Equal(t, "db_app", config.Database.User) - assert.Equal(t, "db_migrator", config.Database.MigrationUser) - assert.Equal(t, "registry", config.Database.Database) - assert.Equal(t, "prefer", config.Database.SSLMode) - assert.Equal(t, int32(10), config.Database.MaxOpenConns) - assert.Equal(t, int32(2), config.Database.MaxIdleConns) - assert.Equal(t, "30m", config.Database.ConnMaxLifetime) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("custom database config", func(t *testing.T) { - t.Parallel() - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "db-custom-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "db-custom-registry", - Sources: []string{"db-custom-source"}, - }, - }, - DatabaseConfig: &mcpv1alpha1.MCPRegistryDatabaseConfig{ - Host: "custom-postgres.example.com", - Port: 15432, - User: "custom_app_user", - MigrationUser: "custom_migrator", - Database: "custom_registry_db", - SSLMode: "require", - MaxOpenConns: 25, - MaxIdleConns: 5, - ConnMaxLifetime: "1h", - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Database) - - // Verify custom values - assert.Equal(t, "custom-postgres.example.com", config.Database.Host) - assert.Equal(t, int32(15432), config.Database.Port) - assert.Equal(t, "custom_app_user", config.Database.User) - assert.Equal(t, "custom_migrator", config.Database.MigrationUser) - assert.Equal(t, "custom_registry_db", config.Database.Database) - assert.Equal(t, "require", config.Database.SSLMode) - assert.Equal(t, int32(25), config.Database.MaxOpenConns) - assert.Equal(t, int32(5), config.Database.MaxIdleConns) - assert.Equal(t, "1h", config.Database.ConnMaxLifetime) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) - - t.Run("partial database config uses defaults for missing fields", func(t *testing.T) { - t.Parallel() - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "db-partial-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "db-partial-registry", - Sources: []string{"db-partial-source"}, - }, - }, - DatabaseConfig: &mcpv1alpha1.MCPRegistryDatabaseConfig{ - Host: "custom-host", - Database: "custom-db", - // Other fields omitted, should use defaults - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Database) - - // Verify custom values are used - assert.Equal(t, "custom-host", config.Database.Host) - assert.Equal(t, "custom-db", config.Database.Database) - - // Verify defaults are used for omitted fields - assert.Equal(t, int32(5432), config.Database.Port) - assert.Equal(t, "db_app", config.Database.User) - assert.Equal(t, "db_migrator", config.Database.MigrationUser) - assert.Equal(t, "prefer", config.Database.SSLMode) - assert.Equal(t, int32(10), config.Database.MaxOpenConns) - assert.Equal(t, int32(2), config.Database.MaxIdleConns) - assert.Equal(t, "30m", config.Database.ConnMaxLifetime) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) -} - -func TestBuildConfig_AuthConfig(t *testing.T) { - t.Parallel() - - // helper to build a source+view pair for auth tests - authSource := func(name string) ([]mcpv1alpha1.MCPRegistrySourceConfig, []mcpv1alpha1.MCPRegistryViewConfig) { - return []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: name + "-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: name, - Sources: []string{name + "-source"}, - }, - } - } - - t.Run("default auth config when nil", func(t *testing.T) { - t.Parallel() - sources, views := authSource("auth-nil-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - // AuthConfig not specified, should default to anonymous - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - assert.Nil(t, config.Auth.OAuth) - }) - - t.Run("explicit anonymous mode", func(t *testing.T) { - t.Parallel() - sources, views := authSource("auth-anonymous-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeAnonymous, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - assert.Nil(t, config.Auth.OAuth) - }) - - t.Run("oauth mode with full config", func(t *testing.T) { - t.Parallel() - sources, views := authSource("auth-oauth-full-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - ResourceURL: "https://registry.example.com", - ScopesSupported: []string{"read", "write", "admin"}, - Realm: "my-registry", - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "keycloak", - IssuerURL: "https://keycloak.example.com/realms/myrealm", - Audience: "registry-api", - ClientID: "registry-client", - ClientSecretRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "keycloak-secret", - }, - Key: "client-secret", - }, - CACertRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "keycloak-ca", - }, - Key: "ca.crt", - }, - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth) - assert.Equal(t, AuthModeOAuth, config.Auth.Mode) - require.NotNil(t, config.Auth.OAuth) - assert.Equal(t, "https://registry.example.com", config.Auth.OAuth.ResourceURL) - assert.Equal(t, []string{"read", "write", "admin"}, config.Auth.OAuth.ScopesSupported) - assert.Equal(t, "my-registry", config.Auth.OAuth.Realm) - - // Should have only the keycloak provider - require.Len(t, config.Auth.OAuth.Providers, 1) - - // Verify keycloak provider - assert.Equal(t, "keycloak", config.Auth.OAuth.Providers[0].Name) - assert.Equal(t, "https://keycloak.example.com/realms/myrealm", config.Auth.OAuth.Providers[0].IssuerURL) - assert.Equal(t, "registry-api", config.Auth.OAuth.Providers[0].Audience) - assert.Equal(t, "registry-client", config.Auth.OAuth.Providers[0].ClientID) - assert.Equal(t, "/secrets/keycloak-secret/client-secret", config.Auth.OAuth.Providers[0].ClientSecretFile) - assert.Equal(t, "/config/certs/keycloak-ca/ca.crt", config.Auth.OAuth.Providers[0].CACertPath) - }) - - t.Run("oauth mode with multiple providers", func(t *testing.T) { - t.Parallel() - sources, views := authSource("auth-multi-provider-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "google", - IssuerURL: "https://accounts.google.com", - Audience: "my-app.apps.googleusercontent.com", - }, - { - Name: "azure", - IssuerURL: "https://login.microsoftonline.com/tenant-id/v2.0", - Audience: "api://my-app", - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth) - assert.Equal(t, AuthModeOAuth, config.Auth.Mode) - require.NotNil(t, config.Auth.OAuth) - - // Should have google and azure providers - require.Len(t, config.Auth.OAuth.Providers, 2) - assert.Equal(t, "google", config.Auth.OAuth.Providers[0].Name) - assert.Equal(t, "azure", config.Auth.OAuth.Providers[1].Name) - }) - - t.Run("oauth mode without oauth config returns error", func(t *testing.T) { - t.Parallel() - sources, views := authSource("auth-oauth-no-config-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: nil, // OAuth mode but no OAuth config - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - // OAuth mode without OAuth config should still work (oauth config is optional) - // and won't add the default kubernetes provider since OAuth is nil - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth) - assert.Equal(t, AuthModeOAuth, config.Auth.Mode) - assert.Nil(t, config.Auth.OAuth) - }) - - t.Run("empty mode defaults to anonymous", func(t *testing.T) { - t.Parallel() - sources, views := authSource("auth-empty-mode-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: "", // Empty mode should default to anonymous - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth) - assert.Equal(t, AuthModeAnonymous, config.Auth.Mode) - }) -} - -func TestBuildOAuthProviderConfig_Validation(t *testing.T) { - t.Parallel() - - // helper for auth provider validation tests - providerSource := func(name string) ([]mcpv1alpha1.MCPRegistrySourceConfig, []mcpv1alpha1.MCPRegistryViewConfig) { - return []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: name + "-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: name, - Sources: []string{name + "-source"}, - }, - } - } - - t.Run("missing provider name", func(t *testing.T) { - t.Parallel() - sources, views := providerSource("provider-no-name-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "", // Missing name - IssuerURL: "https://issuer.example.com", - Audience: "my-app", - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "provider name is required") - assert.Nil(t, config) - }) - - t.Run("missing issuer URL", func(t *testing.T) { - t.Parallel() - sources, views := providerSource("provider-no-issuer-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "my-provider", - IssuerURL: "", // Missing issuer URL - Audience: "my-app", - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "provider issuer URL is required") - assert.Nil(t, config) - }) - - t.Run("missing audience", func(t *testing.T) { - t.Parallel() - sources, views := providerSource("provider-no-audience-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "my-provider", - IssuerURL: "https://issuer.example.com", - Audience: "", // Missing audience - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.Error(t, err) - assert.Contains(t, err.Error(), "provider audience is required") - assert.Nil(t, config) - }) -} - -func TestBuildSecretFilePath(t *testing.T) { - t.Parallel() - - t.Run("nil secret ref returns empty string", func(t *testing.T) { - t.Parallel() - result := buildSecretFilePath(nil) - assert.Equal(t, "", result) - }) - - t.Run("secret ref with key", func(t *testing.T) { - t.Parallel() - secretRef := &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "my-secret", - }, - Key: "my-key", - } - result := buildSecretFilePath(secretRef) - assert.Equal(t, "/secrets/my-secret/my-key", result) - }) - - t.Run("secret ref without key uses default", func(t *testing.T) { - t.Parallel() - secretRef := &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "my-secret", - }, - Key: "", - } - result := buildSecretFilePath(secretRef) - assert.Equal(t, "/secrets/my-secret/clientSecret", result) - }) -} - -func TestBuildCACertFilePath(t *testing.T) { - t.Parallel() - - t.Run("nil configmap ref returns empty string", func(t *testing.T) { - t.Parallel() - result := buildCACertFilePath(nil) - assert.Equal(t, "", result) - }) - - t.Run("configmap ref with key", func(t *testing.T) { - t.Parallel() - configMapRef := &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "my-ca-configmap", - }, - Key: "custom-ca.pem", - } - result := buildCACertFilePath(configMapRef) - assert.Equal(t, "/config/certs/my-ca-configmap/custom-ca.pem", result) - }) - - t.Run("configmap ref without key uses default", func(t *testing.T) { - t.Parallel() - configMapRef := &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "my-ca-configmap", - }, - Key: "", - } - result := buildCACertFilePath(configMapRef) - assert.Equal(t, "/config/certs/my-ca-configmap/ca.crt", result) - }) -} - -func TestBuildOAuthProviderConfig_DirectPaths(t *testing.T) { - t.Parallel() - - // helper for direct path tests - directPathSource := func(name string) ([]mcpv1alpha1.MCPRegistrySourceConfig, []mcpv1alpha1.MCPRegistryViewConfig) { - return []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: name + "-source", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: name, - Sources: []string{name + "-source"}, - }, - } - } - - t.Run("direct caCertPath takes precedence over CACertRef", func(t *testing.T) { - t.Parallel() - sources, views := directPathSource("direct-ca-path-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "my-provider", - IssuerURL: "https://issuer.example.com", - Audience: "my-app", - CaCertPath: "/custom/path/to/ca.crt", // Direct path - CACertRef: &corev1.ConfigMapKeySelector{ // Should be ignored - LocalObjectReference: corev1.LocalObjectReference{ - Name: "ca-configmap", - }, - Key: "ca.crt", - }, - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth.OAuth) - require.Len(t, config.Auth.OAuth.Providers, 1) - // Direct path should be used, not the ref-based path - assert.Equal(t, "/custom/path/to/ca.crt", config.Auth.OAuth.Providers[0].CACertPath) - }) - - t.Run("CACertRef is used when caCertPath is empty", func(t *testing.T) { - t.Parallel() - sources, views := directPathSource("ref-ca-path-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "my-provider", - IssuerURL: "https://issuer.example.com", - Audience: "my-app", - // CaCertPath not set - CACertRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "ca-configmap", - }, - Key: "custom-ca.pem", - }, - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth.OAuth) - require.Len(t, config.Auth.OAuth.Providers, 1) - // Ref-based path should be used - assert.Equal(t, "/config/certs/ca-configmap/custom-ca.pem", config.Auth.OAuth.Providers[0].CACertPath) - }) - - t.Run("direct authTokenFile takes precedence over AuthTokenRef", func(t *testing.T) { - t.Parallel() - sources, views := directPathSource("direct-token-path-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "my-provider", - IssuerURL: "https://issuer.example.com", - Audience: "my-app", - AuthTokenFile: "/var/run/secrets/kubernetes.io/serviceaccount/token", // Direct path - AuthTokenRef: &corev1.SecretKeySelector{ // Should be ignored - LocalObjectReference: corev1.LocalObjectReference{ - Name: "token-secret", - }, - Key: "token", - }, - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth.OAuth) - require.Len(t, config.Auth.OAuth.Providers, 1) - // Direct path should be used, not the ref-based path - assert.Equal(t, "/var/run/secrets/kubernetes.io/serviceaccount/token", config.Auth.OAuth.Providers[0].AuthTokenFile) - }) - - t.Run("AuthTokenRef is used when authTokenFile is empty", func(t *testing.T) { - t.Parallel() - sources, views := directPathSource("ref-token-path-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "my-provider", - IssuerURL: "https://issuer.example.com", - Audience: "my-app", - // AuthTokenFile not set - AuthTokenRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "token-secret", - }, - Key: "my-token", - }, - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth.OAuth) - require.Len(t, config.Auth.OAuth.Providers, 1) - // Ref-based path should be used - assert.Equal(t, "/secrets/token-secret/my-token", config.Auth.OAuth.Providers[0].AuthTokenFile) - }) - - t.Run("provider with all direct paths set", func(t *testing.T) { - t.Parallel() - sources, views := directPathSource("all-direct-paths-registry") - mcpRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: sources, - Registries: views, - AuthConfig: &mcpv1alpha1.MCPRegistryAuthConfig{ - Mode: mcpv1alpha1.MCPRegistryAuthModeOAuth, - OAuth: &mcpv1alpha1.MCPRegistryOAuthConfig{ - Providers: []mcpv1alpha1.MCPRegistryOAuthProviderConfig{ - { - Name: "kubernetes-custom", - IssuerURL: "https://kubernetes.default.svc", - Audience: "my-api", - CaCertPath: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", - AuthTokenFile: "/var/run/secrets/kubernetes.io/serviceaccount/token", - AllowPrivateIP: true, - }, - }, - }, - }, - }, - } - - manager := NewConfigManager(mcpRegistry) - config, err := manager.BuildConfig() - - require.NoError(t, err) - require.NotNil(t, config) - require.NotNil(t, config.Auth.OAuth) - require.Len(t, config.Auth.OAuth.Providers, 1) - - // Verify the custom kubernetes provider - customProvider := config.Auth.OAuth.Providers[0] - assert.Equal(t, "kubernetes-custom", customProvider.Name) - assert.Equal(t, "https://kubernetes.default.svc", customProvider.IssuerURL) - assert.Equal(t, "my-api", customProvider.Audience) - assert.Equal(t, "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", customProvider.CACertPath) - assert.Equal(t, "/var/run/secrets/kubernetes.io/serviceaccount/token", customProvider.AuthTokenFile) - assert.True(t, customProvider.AllowPrivateIP) - }) -} diff --git a/cmd/thv-operator/pkg/registryapi/deployment.go b/cmd/thv-operator/pkg/registryapi/deployment.go index 0508e19577..7700e65b8b 100644 --- a/cmd/thv-operator/pkg/registryapi/deployment.go +++ b/cmd/thv-operator/pkg/registryapi/deployment.go @@ -19,7 +19,6 @@ import ( mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum" ) @@ -82,22 +81,6 @@ func (*manager) CheckAPIReadiness(ctx context.Context, deployment *appsv1.Deploy return false } -// ensureDeployment creates or updates the registry-api Deployment for the MCPRegistry. -// This function builds the deployment via buildRegistryAPIDeployment and delegates -// the create-or-update logic to upsertDeployment. -func (m *manager) ensureDeployment( - ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, - configManager config.ConfigManager, -) (*appsv1.Deployment, error) { - deployment := m.buildRegistryAPIDeployment(ctx, mcpRegistry, configManager) - if deployment == nil { - return nil, fmt.Errorf("failed to build registry-api deployment for %s", mcpRegistry.Name) - } - - return m.upsertDeployment(ctx, mcpRegistry, deployment) -} - // upsertDeployment creates or updates a registry-api Deployment for the given MCPRegistry. // It sets the owner reference, checks for an existing deployment, and either creates, // updates (preserving Spec.Replicas for HPA compatibility), or skips if already up-to-date. @@ -170,106 +153,15 @@ func (m *manager) upsertDeployment( return existing, nil } -// buildRegistryAPIDeployment creates and configures a Deployment object for the registry API. -// This function handles all deployment configuration including labels, container specs, probes, -// and storage manager integration. It returns a fully configured deployment ready for Kubernetes API operations. -func (*manager) buildRegistryAPIDeployment( - ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, - configManager config.ConfigManager, -) *appsv1.Deployment { - ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) - // Generate deployment name using the established pattern - deploymentName := mcpRegistry.GetAPIResourceName() - - // Define labels using common function - labels := labelsForRegistryAPI(mcpRegistry, deploymentName) - - // Parse user-provided PodTemplateSpec if present - var userPTS *corev1.PodTemplateSpec - if mcpRegistry.HasPodTemplateSpec() { - var err error - userPTS, err = ParsePodTemplateSpec(mcpRegistry.GetPodTemplateSpecRaw()) - if err != nil { - ctxLogger.Error(err, "Failed to parse PodTemplateSpec") - return nil - } - } - - // Compute config hash from the full MCPRegistry spec to detect any spec changes - configHash := ctrlutil.CalculateConfigHash(mcpRegistry.Spec) - - // Build list of options for PodTemplateSpec - opts := []PodTemplateSpecOption{ - WithLabels(labels), - WithAnnotations(map[string]string{ - configHashAnnotation: configHash, - }), - WithServiceAccountName(GetServiceAccountName(mcpRegistry)), - WithContainer(BuildRegistryAPIContainer(getRegistryAPIImage())), - WithRegistryServerConfigMount(RegistryAPIContainerName, configManager.GetRegistryServerConfigMapName()), - WithRegistrySourceMounts(RegistryAPIContainerName, mcpRegistry.Spec.Sources), - WithRegistryStorageMount(RegistryAPIContainerName), - } - - // Add pgpass mount if databaseConfig is specified - if mcpRegistry.HasDatabaseConfig() { - secretName := mcpRegistry.BuildPGPassSecretName() - opts = append(opts, WithPGPassMount(RegistryAPIContainerName, secretName)) - } - - // Add git auth mounts for sources that have authentication configured - for _, source := range mcpRegistry.Spec.Sources { - if source.Git != nil && source.Git.Auth != nil { - opts = append(opts, WithGitAuthMount(RegistryAPIContainerName, source.Git.Auth.PasswordSecretRef)) - } - } - - // Build PodTemplateSpec with defaults and user customizations merged - builder := NewPodTemplateSpecBuilderFrom(userPTS) - podTemplateSpec := builder.Apply(opts...).Build() - - // Build deployment-level annotations with PodTemplateSpec hash for change detection - deploymentAnnotations := make(map[string]string) - if mcpRegistry.HasPodTemplateSpec() && mcpRegistry.Spec.PodTemplateSpec.Raw != nil { - hash, err := checksum.HashRawJSON(mcpRegistry.Spec.PodTemplateSpec.Raw) - if err == nil { - deploymentAnnotations[podTemplateSpecHashAnnotation] = hash - } - } - - // Create basic deployment specification with named container - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: deploymentName, - Namespace: mcpRegistry.Namespace, - Labels: labels, - Annotations: deploymentAnnotations, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &[]int32{DefaultReplicas}[0], // Single replica for registry API - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app.kubernetes.io/name": deploymentName, - "app.kubernetes.io/component": "registry-api", - }, - }, - Template: podTemplateSpec, - }, - } - - return deployment -} - -// ensureDeploymentNewPath creates or updates the registry-api Deployment for the new -// decoupled config path. It builds the deployment via buildRegistryAPIDeploymentNewPath -// and delegates the create-or-update logic to upsertDeployment. -func (m *manager) ensureDeploymentNewPath( +// ensureDeployment creates or updates the registry-api Deployment for the MCPRegistry. +// It builds the deployment via buildRegistryAPIDeployment and delegates the create-or-update +// logic to upsertDeployment. +func (m *manager) ensureDeployment( ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry, configMapName string, ) (*appsv1.Deployment, error) { - deployment, err := m.buildRegistryAPIDeploymentNewPath(ctx, mcpRegistry, configMapName) + deployment, err := m.buildRegistryAPIDeployment(ctx, mcpRegistry, configMapName) if err != nil { return nil, fmt.Errorf("failed to build deployment: %w", err) } @@ -277,12 +169,10 @@ func (m *manager) ensureDeploymentNewPath( return m.upsertDeployment(ctx, mcpRegistry, deployment) } -// buildRegistryAPIDeploymentNewPath creates a Deployment for the decoupled config path. -// Unlike buildRegistryAPIDeployment which uses a ConfigManager to generate config, this -// function mounts a ConfigMap created from the raw ConfigYAML string. It supports -// user-provided Volumes, VolumeMounts, and PGPassSecretRef instead of the legacy -// Sources, DatabaseConfig, and auto-generated pgpass secret. -func (*manager) buildRegistryAPIDeploymentNewPath( +// buildRegistryAPIDeployment creates a Deployment for the registry API. It mounts a ConfigMap +// created from the raw ConfigYAML string and supports user-provided Volumes, VolumeMounts, +// and PGPassSecretRef. +func (*manager) buildRegistryAPIDeployment( ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry, configMapName string, diff --git a/cmd/thv-operator/pkg/registryapi/deployment_test.go b/cmd/thv-operator/pkg/registryapi/deployment_test.go index 4e00c5b343..fe140e3eea 100644 --- a/cmd/thv-operator/pkg/registryapi/deployment_test.go +++ b/cmd/thv-operator/pkg/registryapi/deployment_test.go @@ -9,357 +9,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/intstr" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" ) -func TestManagerBuildRegistryAPIDeployment(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mcpRegistry *mcpv1alpha1.MCPRegistry - setupMocks func() - expectedError string - validateResult func(*testing.T, *appsv1.Deployment) - }{ - { - name: "successful deployment creation", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - }, - }, - setupMocks: func() { - }, - validateResult: func(t *testing.T, deployment *appsv1.Deployment) { - t.Helper() - require.NotNil(t, deployment) - - // Verify basic metadata - assert.Equal(t, "test-registry-api", deployment.Name) - assert.Equal(t, "test-namespace", deployment.Namespace) - - // Verify labels - expectedLabels := map[string]string{ - "app.kubernetes.io/name": "test-registry-api", - "app.kubernetes.io/component": "registry-api", - "app.kubernetes.io/managed-by": "toolhive-operator", - "toolhive.stacklok.io/registry-name": "test-registry", - } - assert.Equal(t, expectedLabels, deployment.Labels) - - // Verify replica count - assert.Equal(t, int32(1), *deployment.Spec.Replicas) - - // Verify selector - expectedSelector := map[string]string{ - "app.kubernetes.io/name": "test-registry-api", - "app.kubernetes.io/component": "registry-api", - } - assert.Equal(t, expectedSelector, deployment.Spec.Selector.MatchLabels) - - // Verify pod template labels - assert.Equal(t, expectedLabels, deployment.Spec.Template.Labels) - - // Verify pod template annotations - config hash should be a real computed hash, not a dummy - configHash := deployment.Spec.Template.Annotations["toolhive.stacklok.dev/config-hash"] - assert.NotEmpty(t, configHash) - assert.NotEqual(t, "hash-dummy-value", configHash) - - // Verify service account uses the dynamically generated name (registry-name + "-registry-api") - assert.Equal(t, "test-registry-registry-api", deployment.Spec.Template.Spec.ServiceAccountName) - - // Verify containers - require.Len(t, deployment.Spec.Template.Spec.Containers, 1) - container := deployment.Spec.Template.Spec.Containers[0] - assert.Equal(t, RegistryAPIContainerName, container.Name) - assert.Equal(t, getRegistryAPIImage(), container.Image) - - // Verify container ports - require.Len(t, container.Ports, 1) - port := container.Ports[0] - assert.Equal(t, int32(RegistryAPIPort), port.ContainerPort) - assert.Equal(t, RegistryAPIPortName, port.Name) - assert.Equal(t, corev1.ProtocolTCP, port.Protocol) - - // Verify resource requirements - assert.Equal(t, resource.MustParse(DefaultCPURequest), container.Resources.Requests[corev1.ResourceCPU]) - assert.Equal(t, resource.MustParse(DefaultMemoryRequest), container.Resources.Requests[corev1.ResourceMemory]) - assert.Equal(t, resource.MustParse(DefaultCPULimit), container.Resources.Limits[corev1.ResourceCPU]) - assert.Equal(t, resource.MustParse(DefaultMemoryLimit), container.Resources.Limits[corev1.ResourceMemory]) - - // Verify liveness probe - require.NotNil(t, container.LivenessProbe) - assert.Equal(t, HealthCheckPath, container.LivenessProbe.HTTPGet.Path) - assert.Equal(t, intstr.FromInt32(RegistryAPIPort), container.LivenessProbe.HTTPGet.Port) - assert.Equal(t, int32(LivenessInitialDelay), container.LivenessProbe.InitialDelaySeconds) - assert.Equal(t, int32(LivenessPeriod), container.LivenessProbe.PeriodSeconds) - - // Verify readiness probe - require.NotNil(t, container.ReadinessProbe) - assert.Equal(t, ReadinessCheckPath, container.ReadinessProbe.HTTPGet.Path) - assert.Equal(t, intstr.FromInt32(RegistryAPIPort), container.ReadinessProbe.HTTPGet.Port) - assert.Equal(t, int32(ReadinessInitialDelay), container.ReadinessProbe.InitialDelaySeconds) - assert.Equal(t, int32(ReadinessPeriod), container.ReadinessProbe.PeriodSeconds) - - }, - }, - { - name: "user PodTemplateSpec merged correctly", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - PodTemplateSpec: &runtime.RawExtension{ - Raw: []byte(`{"spec":{"serviceAccountName":"custom-sa"}}`), - }, - }, - }, - setupMocks: func() { - }, - validateResult: func(t *testing.T, deployment *appsv1.Deployment) { - t.Helper() - require.NotNil(t, deployment) - - // User-provided service account name should take precedence - assert.Equal(t, "custom-sa", deployment.Spec.Template.Spec.ServiceAccountName) - - // Default volumes and mounts should still be present - volumes := deployment.Spec.Template.Spec.Volumes - assert.True(t, hasVolume(volumes, RegistryServerConfigVolumeName)) - assert.True(t, hasVolume(volumes, "storage-data")) - assert.True(t, hasVolume(volumes, "registry-data-source-default")) - }, - }, - { - name: "git auth secrets mounted correctly", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-git", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "git", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-credentials", - }, - Key: "token", - }, - }, - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "private-git", - Sources: []string{"private-git"}, - }, - }, - }, - }, - setupMocks: func() { - }, - validateResult: func(t *testing.T, deployment *appsv1.Deployment) { - t.Helper() - require.NotNil(t, deployment) - - // Verify git auth volume exists - volumes := deployment.Spec.Template.Spec.Volumes - assert.True(t, hasVolume(volumes, "git-auth-git-credentials"), "git auth volume should exist") - - // Find the git auth volume and verify its configuration - var gitAuthVolume *corev1.Volume - for i := range volumes { - if volumes[i].Name == "git-auth-git-credentials" { - gitAuthVolume = &volumes[i] - break - } - } - require.NotNil(t, gitAuthVolume) - require.NotNil(t, gitAuthVolume.Secret) - assert.Equal(t, "git-credentials", gitAuthVolume.Secret.SecretName) - require.Len(t, gitAuthVolume.Secret.Items, 1) - assert.Equal(t, "token", gitAuthVolume.Secret.Items[0].Key) - - // Verify container has git auth volume mount - require.Len(t, deployment.Spec.Template.Spec.Containers, 1) - container := deployment.Spec.Template.Spec.Containers[0] - - var gitAuthMount *corev1.VolumeMount - for i := range container.VolumeMounts { - if container.VolumeMounts[i].Name == "git-auth-git-credentials" { - gitAuthMount = &container.VolumeMounts[i] - break - } - } - require.NotNil(t, gitAuthMount, "git auth volume mount should exist") - assert.Equal(t, "/secrets/git-credentials", gitAuthMount.MountPath) - assert.True(t, gitAuthMount.ReadOnly) - }, - }, - { - name: "multiple git auth secrets mounted for multiple registries", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-git-1", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo-1.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "git", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-credentials-1", - }, - Key: "token", - }, - }, - }, - }, - { - Name: "private-git-2", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/example/private-repo-2.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "git", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-credentials-2", - }, - Key: "password", - }, - }, - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "private-git-1", - Sources: []string{"private-git-1"}, - }, - { - Name: "private-git-2", - Sources: []string{"private-git-2"}, - }, - }, - }, - }, - setupMocks: func() { - }, - validateResult: func(t *testing.T, deployment *appsv1.Deployment) { - t.Helper() - require.NotNil(t, deployment) - - // Verify both git auth volumes exist - volumes := deployment.Spec.Template.Spec.Volumes - assert.True(t, hasVolume(volumes, "git-auth-git-credentials-1"), "git auth volume 1 should exist") - assert.True(t, hasVolume(volumes, "git-auth-git-credentials-2"), "git auth volume 2 should exist") - - // Verify container has both git auth volume mounts - require.Len(t, deployment.Spec.Template.Spec.Containers, 1) - container := deployment.Spec.Template.Spec.Containers[0] - - mountPaths := make(map[string]bool) - for _, mount := range container.VolumeMounts { - mountPaths[mount.MountPath] = true - } - assert.True(t, mountPaths["/secrets/git-credentials-1"], "git auth mount 1 should exist") - assert.True(t, mountPaths["/secrets/git-credentials-2"], "git auth mount 2 should exist") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - tt.setupMocks() - - manager := &manager{} - - configManager := config.NewConfigManager(tt.mcpRegistry) - deployment := manager.buildRegistryAPIDeployment(context.Background(), tt.mcpRegistry, configManager) - tt.validateResult(t, deployment) - }) - } -} - func TestGetRegistryAPIImage(t *testing.T) { t.Parallel() @@ -872,6 +529,8 @@ func TestDeploymentNeedsUpdate(t *testing.T) { func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { t.Parallel() + const baseConfigYAML = "sources:\n - name: k8s\n kubernetes: {}\n" + t.Run("no podtemplatespec has no hash annotation", func(t *testing.T) { t.Parallel() mgr := &manager{} @@ -881,16 +540,11 @@ func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { Namespace: "test-namespace", }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "default", Format: mcpv1alpha1.RegistryFormatToolHive}, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - {Name: "default", Sources: []string{"default"}}, - }, + ConfigYAML: baseConfigYAML, }, } - configManager := config.NewConfigManager(mcpRegistry) - deployment := mgr.buildRegistryAPIDeployment(context.Background(), mcpRegistry, configManager) + deployment, err := mgr.buildRegistryAPIDeployment(context.Background(), mcpRegistry, "test-registry-registry-server-config") + require.NoError(t, err) require.NotNil(t, deployment) _, hasPTSHash := deployment.Annotations[podTemplateSpecHashAnnotation] @@ -906,19 +560,14 @@ func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { Namespace: "test-namespace", }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "default", Format: mcpv1alpha1.RegistryFormatToolHive}, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - {Name: "default", Sources: []string{"default"}}, - }, + ConfigYAML: baseConfigYAML, PodTemplateSpec: &runtime.RawExtension{ Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"registry-creds"}]}}`), }, }, } - configManager := config.NewConfigManager(mcpRegistry) - deployment := mgr.buildRegistryAPIDeployment(context.Background(), mcpRegistry, configManager) + deployment, err := mgr.buildRegistryAPIDeployment(context.Background(), mcpRegistry, "test-registry-registry-server-config") + require.NoError(t, err) require.NotNil(t, deployment) ptsHash, hasPTSHash := deployment.Annotations[podTemplateSpecHashAnnotation] @@ -929,363 +578,29 @@ func TestBuildRegistryAPIDeployment_PodTemplateSpecHash(t *testing.T) { t.Run("different podtemplatespec produces different hash", func(t *testing.T) { t.Parallel() mgr := &manager{} - baseSources := []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "default", Format: mcpv1alpha1.RegistryFormatToolHive}, - } - baseRegistries := []mcpv1alpha1.MCPRegistryViewConfig{ - {Name: "default", Sources: []string{"default"}}, - } registry1 := &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: baseSources, - Registries: baseRegistries, + ConfigYAML: baseConfigYAML, PodTemplateSpec: &runtime.RawExtension{Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"creds-a"}]}}`)}, }, } registry2 := &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"}, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: baseSources, - Registries: baseRegistries, + ConfigYAML: baseConfigYAML, PodTemplateSpec: &runtime.RawExtension{Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"creds-b"}]}}`)}, }, } - d1 := mgr.buildRegistryAPIDeployment(context.Background(), registry1, config.NewConfigManager(registry1)) - d2 := mgr.buildRegistryAPIDeployment(context.Background(), registry2, config.NewConfigManager(registry2)) + d1, err1 := mgr.buildRegistryAPIDeployment(context.Background(), registry1, "test-registry-server-config") + d2, err2 := mgr.buildRegistryAPIDeployment(context.Background(), registry2, "test-registry-server-config") + require.NoError(t, err1) + require.NoError(t, err2) require.NotNil(t, d1) require.NotNil(t, d2) assert.NotEqual(t, d1.Annotations[podTemplateSpecHashAnnotation], d2.Annotations[podTemplateSpecHashAnnotation]) }) } - -func TestEnsureDeployment(t *testing.T) { - t.Parallel() - - newScheme := func() *runtime.Scheme { - s := runtime.NewScheme() - _ = mcpv1alpha1.AddToScheme(s) - _ = appsv1.AddToScheme(s) - _ = corev1.AddToScheme(s) - return s - } - - baseMCPRegistry := func() *mcpv1alpha1.MCPRegistry { - return &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - UID: types.UID("test-uid"), - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - }, - } - } - - t.Run("creates deployment when none exists", func(t *testing.T) { - t.Parallel() - - scheme := newScheme() - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - mgr := &manager{client: fakeClient, scheme: scheme} - - mcpRegistry := baseMCPRegistry() - configManager := config.NewConfigManager(mcpRegistry) - - deployment, err := mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - require.NotNil(t, deployment) - - // Fetch from fake client to verify it was actually created - fetched := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, fetched) - require.NoError(t, err) - - assert.Equal(t, "test-registry-api", fetched.Name) - assert.Equal(t, "test-namespace", fetched.Namespace) - - // Verify labels - assert.Equal(t, "test-registry-api", fetched.Labels["app.kubernetes.io/name"]) - assert.Equal(t, "registry-api", fetched.Labels["app.kubernetes.io/component"]) - assert.Equal(t, "toolhive-operator", fetched.Labels["app.kubernetes.io/managed-by"]) - assert.Equal(t, "test-registry", fetched.Labels["toolhive.stacklok.io/registry-name"]) - - // Verify config-hash annotation on pod template - configHash := fetched.Spec.Template.Annotations[configHashAnnotation] - assert.NotEmpty(t, configHash) - - // Verify container image - require.Len(t, fetched.Spec.Template.Spec.Containers, 1) - assert.Equal(t, getRegistryAPIImage(), fetched.Spec.Template.Spec.Containers[0].Image) - }) - - t.Run("updates deployment when MCPRegistry spec changes", func(t *testing.T) { - t.Parallel() - - scheme := newScheme() - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - mgr := &manager{client: fakeClient, scheme: scheme} - - mcpRegistry := baseMCPRegistry() - configManager := config.NewConfigManager(mcpRegistry) - - // First call: create - _, err := mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Capture the original config hash - original := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, original) - require.NoError(t, err) - originalHash := original.Spec.Template.Annotations[configHashAnnotation] - - // Modify the spec by adding a source entry - mcpRegistry.Spec.Sources = append(mcpRegistry.Spec.Sources, mcpv1alpha1.MCPRegistrySourceConfig{ - Name: "extra", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "extra-cm"}, - Key: "extra.json", - }, - }) - mcpRegistry.Spec.Registries = append(mcpRegistry.Spec.Registries, mcpv1alpha1.MCPRegistryViewConfig{ - Name: "extra", - Sources: []string{"extra"}, - }) - configManager = config.NewConfigManager(mcpRegistry) - - // Second call: update - _, err = mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Fetch updated deployment - updated := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, updated) - require.NoError(t, err) - - updatedHash := updated.Spec.Template.Annotations[configHashAnnotation] - assert.NotEqual(t, originalHash, updatedHash, "config-hash annotation should change when spec changes") - }) - - t.Run("updates deployment when PodTemplateSpec is added", func(t *testing.T) { - t.Parallel() - - scheme := newScheme() - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - mgr := &manager{client: fakeClient, scheme: scheme} - - mcpRegistry := baseMCPRegistry() - configManager := config.NewConfigManager(mcpRegistry) - - // First call: create without PodTemplateSpec - _, err := mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Add PodTemplateSpec with imagePullSecrets - mcpRegistry.Spec.PodTemplateSpec = &runtime.RawExtension{ - Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"my-secret"}]}}`), - } - configManager = config.NewConfigManager(mcpRegistry) - - // Second call: update - _, err = mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Fetch updated deployment - fetched := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, fetched) - require.NoError(t, err) - - // Verify imagePullSecrets appeared - require.NotEmpty(t, fetched.Spec.Template.Spec.ImagePullSecrets) - assert.Equal(t, "my-secret", fetched.Spec.Template.Spec.ImagePullSecrets[0].Name) - - // Verify podtemplatespec-hash annotation is now set - ptsHash, hasPTSHash := fetched.Annotations[podTemplateSpecHashAnnotation] - assert.True(t, hasPTSHash, "should have podtemplatespec-hash annotation after adding PodTemplateSpec") - assert.NotEmpty(t, ptsHash) - }) - - t.Run("updates deployment when PodTemplateSpec changes", func(t *testing.T) { - t.Parallel() - - scheme := newScheme() - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - mgr := &manager{client: fakeClient, scheme: scheme} - - mcpRegistry := baseMCPRegistry() - mcpRegistry.Spec.PodTemplateSpec = &runtime.RawExtension{ - Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"secret-a"}]}}`), - } - configManager := config.NewConfigManager(mcpRegistry) - - // First call: create with PodTemplateSpec - _, err := mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - original := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, original) - require.NoError(t, err) - originalPTSHash := original.Annotations[podTemplateSpecHashAnnotation] - - // Change the PodTemplateSpec - mcpRegistry.Spec.PodTemplateSpec = &runtime.RawExtension{ - Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"secret-b"}]}}`), - } - configManager = config.NewConfigManager(mcpRegistry) - - // Second call: update - _, err = mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Fetch updated deployment - fetched := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, fetched) - require.NoError(t, err) - - // Verify the imagePullSecrets changed - require.NotEmpty(t, fetched.Spec.Template.Spec.ImagePullSecrets) - assert.Equal(t, "secret-b", fetched.Spec.Template.Spec.ImagePullSecrets[0].Name) - - // Verify the podtemplatespec-hash annotation changed - updatedPTSHash := fetched.Annotations[podTemplateSpecHashAnnotation] - assert.NotEqual(t, originalPTSHash, updatedPTSHash, "podtemplatespec-hash should change when PodTemplateSpec changes") - }) - - t.Run("skips update when nothing changed", func(t *testing.T) { - t.Parallel() - - scheme := newScheme() - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - mgr := &manager{client: fakeClient, scheme: scheme} - - mcpRegistry := baseMCPRegistry() - configManager := config.NewConfigManager(mcpRegistry) - - // First call: create - _, err := mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Capture ResourceVersion after creation - created := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, created) - require.NoError(t, err) - originalResourceVersion := created.ResourceVersion - - // Second call: same spec, should skip update - _, err = mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Fetch again and verify ResourceVersion did not change - afterSecondCall := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, afterSecondCall) - require.NoError(t, err) - - assert.Equal(t, originalResourceVersion, afterSecondCall.ResourceVersion, - "ResourceVersion should not change when no update is needed") - }) - - t.Run("preserves Spec.Replicas on update", func(t *testing.T) { - t.Parallel() - - scheme := newScheme() - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - mgr := &manager{client: fakeClient, scheme: scheme} - - mcpRegistry := baseMCPRegistry() - configManager := config.NewConfigManager(mcpRegistry) - - // First call: create - _, err := mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Simulate HPA scaling the deployment to 3 replicas - existing := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, existing) - require.NoError(t, err) - - hpaReplicas := int32(3) - existing.Spec.Replicas = &hpaReplicas - err = fakeClient.Update(context.Background(), existing) - require.NoError(t, err) - - // Modify the MCPRegistry spec to trigger an update - mcpRegistry.Spec.Sources = append(mcpRegistry.Spec.Sources, mcpv1alpha1.MCPRegistrySourceConfig{ - Name: "extra", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "extra-cm"}, - Key: "extra.json", - }, - }) - mcpRegistry.Spec.Registries = append(mcpRegistry.Spec.Registries, mcpv1alpha1.MCPRegistryViewConfig{ - Name: "extra", - Sources: []string{"extra"}, - }) - configManager = config.NewConfigManager(mcpRegistry) - - // Second call: update triggered by spec change - _, err = mgr.ensureDeployment(context.Background(), mcpRegistry, configManager) - require.NoError(t, err) - - // Fetch and verify replicas were preserved - updated := &appsv1.Deployment{} - err = fakeClient.Get(context.Background(), client.ObjectKey{ - Name: "test-registry-api", - Namespace: "test-namespace", - }, updated) - require.NoError(t, err) - - require.NotNil(t, updated.Spec.Replicas) - assert.Equal(t, int32(3), *updated.Spec.Replicas, - "Spec.Replicas should be preserved after update (HPA scaling should not be overwritten)") - }) -} diff --git a/cmd/thv-operator/pkg/registryapi/manager.go b/cmd/thv-operator/pkg/registryapi/manager.go index ec7279de10..9878496405 100644 --- a/cmd/thv-operator/pkg/registryapi/manager.go +++ b/cmd/thv-operator/pkg/registryapi/manager.go @@ -41,23 +41,13 @@ func NewManager( // This method coordinates all aspects of API service including creating/updating the deployment and service, // checking readiness, and updating the MCPRegistry status with deployment references and endpoint information. // -// When ConfigYAML is set on the MCPRegistry spec, the decoupled reconciliation path is used. -// Otherwise, the legacy path is used for backward compatibility. +// It creates a ConfigMap from the raw ConfigYAML string and mounts user-provided volumes directly, +// without parsing or transforming config. func (m *manager) ReconcileAPIService( ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry, ) *Error { - if mcpRegistry.Spec.ConfigYAML != "" { - return m.reconcileNewPath(ctx, mcpRegistry) - } - return m.reconcileLegacyPath(ctx, mcpRegistry) -} - -// reconcileNewPath handles reconciliation for MCPRegistry resources that use the -// decoupled ConfigYAML field. It creates a ConfigMap from the raw YAML string and -// mounts user-provided volumes directly, without parsing or transforming config. -func (m *manager) reconcileNewPath(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) *Error { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) - ctxLogger.Info("Reconciling API service (new config path)") + ctxLogger.Info("Reconciling API service") // Create config ConfigMap from raw YAML configMap, err := config.RawConfigToConfigMap(mcpRegistry.Name, mcpRegistry.Namespace, mcpRegistry.Spec.ConfigYAML) @@ -94,7 +84,7 @@ func (m *manager) reconcileNewPath(ctx context.Context, mcpRegistry *mcpv1alpha1 } // Ensure deployment exists and is configured correctly - deployment, err := m.ensureDeploymentNewPath(ctx, mcpRegistry, configMapName) + deployment, err := m.ensureDeployment(ctx, mcpRegistry, configMapName) if err != nil { ctxLogger.Error(err, "Failed to ensure deployment") return &Error{ @@ -126,88 +116,6 @@ func (m *manager) reconcileNewPath(ctx context.Context, mcpRegistry *mcpv1alpha1 return nil } -// reconcileLegacyPath handles reconciliation for MCPRegistry resources that use -// the legacy typed fields (Sources, Registries, DatabaseConfig, etc.). -func (m *manager) reconcileLegacyPath(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) *Error { - ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) - ctxLogger.Info("Reconciling API service") - - // Create ConfigManager for building configuration - configManager := config.NewConfigManager(mcpRegistry) - - // Ensure registry server config ConfigMap exists - err := m.ensureRegistryServerConfigConfigMap(ctx, mcpRegistry, configManager) - if err != nil { - ctxLogger.Error(err, "Failed to ensure registry server config config map") - return &Error{ - Err: err, - Message: fmt.Sprintf("Failed to ensure registry server config config map: %v", err), - ConditionReason: "ConfigMapFailed", - } - } - - // Ensure RBAC resources (ServiceAccount, Role, RoleBinding) before deployment - err = m.ensureRBACResources(ctx, mcpRegistry) - if err != nil { - ctxLogger.Error(err, "Failed to ensure RBAC resources") - return &Error{ - Err: err, - Message: fmt.Sprintf("Failed to ensure RBAC resources: %v", err), - ConditionReason: "RBACFailed", - } - } - - // Ensure pgpass secret for PostgreSQL authentication if dbConfig provided. - if mcpRegistry.HasDatabaseConfig() { - err = m.ensurePGPassSecret(ctx, mcpRegistry) - if err != nil { - ctxLogger.Error(err, "Failed to ensure pgpass secret") - return &Error{ - Err: err, - Message: fmt.Sprintf("Failed to ensure pgpass secret: %v", err), - ConditionReason: "PGPassSecretFailed", - } - } - } - - // Step 1: Ensure deployment exists and is configured correctly - deployment, err := m.ensureDeployment(ctx, mcpRegistry, configManager) - if err != nil { - ctxLogger.Error(err, "Failed to ensure deployment") - return &Error{ - Err: err, - Message: fmt.Sprintf("Failed to ensure deployment: %v", err), - ConditionReason: "DeploymentFailed", - } - } - - // Step 2: Ensure service exists and is configured correctly - err = m.ensureService(ctx, mcpRegistry) - if err != nil { - ctxLogger.Error(err, "Failed to ensure service") - return &Error{ - Err: err, - Message: fmt.Sprintf("Failed to ensure service: %v", err), - ConditionReason: "ServiceFailed", - } - } - - // Step 3: Check API readiness - isReady := m.CheckAPIReadiness(ctx, deployment) - - // Note: Status updates are now handled by the controller - // The controller can call IsAPIReady to check readiness and update status accordingly - - // Step 4: Log completion status - if isReady { - ctxLogger.Info("API service reconciliation completed successfully - API is ready") - } else { - ctxLogger.Info("API service reconciliation completed - API is not ready yet") - } - - return nil -} - // IsAPIReady checks if the registry API deployment is ready and serving requests func (m *manager) IsAPIReady(ctx context.Context, mcpRegistry *mcpv1alpha1.MCPRegistry) bool { ctxLogger := log.FromContext(ctx).WithValues("mcpregistry", mcpRegistry.Name) @@ -269,12 +177,6 @@ func (m *manager) GetAPIStatus(ctx context.Context, mcpRegistry *mcpv1alpha1.MCP return m.CheckAPIReadiness(ctx, deployment), deployment.Status.ReadyReplicas } -// getConfigMapName generates the ConfigMap name for registry storage -// This mirrors the logic in ConfigMapStorageManager to maintain consistency -func getConfigMapName(mcpRegistry *mcpv1alpha1.MCPRegistry) string { - return mcpRegistry.GetStorageName() -} - // labelsForRegistryAPI generates standard labels for registry API resources func labelsForRegistryAPI(mcpRegistry *mcpv1alpha1.MCPRegistry, resourceName string) map[string]string { return map[string]string{ @@ -284,27 +186,3 @@ func labelsForRegistryAPI(mcpRegistry *mcpv1alpha1.MCPRegistry, resourceName str "toolhive.stacklok.io/registry-name": mcpRegistry.Name, } } - -func (m *manager) ensureRegistryServerConfigConfigMap( - ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, - configManager config.ConfigManager, -) error { - cfg, err := configManager.BuildConfig() - if err != nil { - return fmt.Errorf("failed to build registry server config configuration: %w", err) - } - - configMap, err := cfg.ToConfigMapWithContentChecksum(mcpRegistry) - if err != nil { - return fmt.Errorf("failed to create config map: %w", err) - } - - // Use the kubernetes configmaps client for upsert operations - configMapsClient := configmaps.NewClient(m.client, m.scheme) - if _, err := configMapsClient.UpsertWithOwnerReference(ctx, configMap, mcpRegistry); err != nil { - return fmt.Errorf("failed to upsert registry server config config map: %w", err) - } - - return nil -} diff --git a/cmd/thv-operator/pkg/registryapi/manager_test.go b/cmd/thv-operator/pkg/registryapi/manager_test.go index 5b33921ff7..e3a2f2bc9e 100644 --- a/cmd/thv-operator/pkg/registryapi/manager_test.go +++ b/cmd/thv-operator/pkg/registryapi/manager_test.go @@ -76,34 +76,14 @@ func TestReconcileAPIService(t *testing.T) { WithScheme(scheme). Build() - // Create test MCPRegistry + // Create test MCPRegistry with configYAML mcpRegistry := &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "10m", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, + ConfigYAML: "sources:\n - name: default\n format: toolhive\n syncPolicy:\n interval: 10m\nregistries:\n - name: default\n sources: [\"default\"]\n", }, } @@ -138,7 +118,7 @@ func TestReconcileAPIService(t *testing.T) { configYAML := foundConfigMap.Data["config.yaml"] assert.NotEmpty(t, configYAML, "config.yaml should not be empty") - // Verify the content includes expected configuration + // Verify the content matches the raw configYAML (operator passes it through unchanged) assert.Contains(t, configYAML, "name: default") assert.Contains(t, configYAML, "format: toolhive") assert.Contains(t, configYAML, "interval: 10m") @@ -167,34 +147,14 @@ func TestReconcileAPIService(t *testing.T) { }). Build() - // Create test MCPRegistry + // Create test MCPRegistry with configYAML mcpRegistry := &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "test-registry", Namespace: "test-namespace", }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "test-configmap", - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "10m", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, + ConfigYAML: "sources:\n - name: default\n format: toolhive\n", }, } @@ -205,10 +165,8 @@ func TestReconcileAPIService(t *testing.T) { // Verify that an error is returned assert.NotNil(t, result, "Expected an error when ConfigMap upsert fails") - assert.Contains(t, result.Error(), "Failed to ensure registry server config config map", + assert.Contains(t, result.Error(), "Failed to upsert registry server config config map", "Error should indicate registry server config ConfigMap failure") - assert.Contains(t, result.Error(), "failed to upsert registry server config config map", - "Error should indicate upsert operation failure") assert.Contains(t, result.Error(), "simulated ConfigMap operation failure", "Error should include the underlying client error") }) diff --git a/cmd/thv-operator/pkg/registryapi/pgpass.go b/cmd/thv-operator/pkg/registryapi/pgpass.go deleted file mode 100644 index b7c9737f89..0000000000 --- a/cmd/thv-operator/pkg/registryapi/pgpass.go +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package registryapi - -import ( - "context" - "fmt" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" -) - -const ( - // pgpassSecretKey is the key name for the pgpass file content in the generated secret - pgpassSecretKey = ".pgpass" -) - -// GetPGPassSecretKey returns the key name used for the pgpass file content in secrets -func GetPGPassSecretKey() string { - return pgpassSecretKey -} - -// ensurePGPassSecret creates or updates the pgpass secret for the given MCPRegistry. -// It reads passwords from the referenced secrets and generates a pgpass file with -// entries for both the application user and migration user. -func (m *manager) ensurePGPassSecret( - ctx context.Context, - mcpRegistry *mcpv1alpha1.MCPRegistry, -) error { - dbConfig := mcpRegistry.GetDatabaseConfig() - - // Read app user password from secret - appUserPassword, err := m.kubeHelper.Secrets.GetValue(ctx, mcpRegistry.Namespace, dbConfig.DBAppUserPasswordSecretRef) - if err != nil { - return fmt.Errorf("failed to read app user password from secret %s: %w", - dbConfig.DBAppUserPasswordSecretRef.Name, err) - } - - // Read migration user password from secret - migrationUserPassword, err := m.kubeHelper.Secrets.GetValue( - ctx, mcpRegistry.Namespace, dbConfig.DBMigrationUserPasswordSecretRef) - if err != nil { - return fmt.Errorf("failed to read migration user password from secret %s: %w", - dbConfig.DBMigrationUserPasswordSecretRef.Name, err) - } - - // Build pgpass file content - // Format: hostname:port:database:username:password - pgpassContent := fmt.Sprintf("%s:%d:%s:%s:%s\n%s:%d:%s:%s:%s\n", - dbConfig.Host, mcpRegistry.GetDatabasePort(), dbConfig.Database, dbConfig.User, appUserPassword, - dbConfig.Host, mcpRegistry.GetDatabasePort(), dbConfig.Database, dbConfig.MigrationUser, migrationUserPassword, - ) - - // Create the pgpass secret - secretName := mcpRegistry.BuildPGPassSecretName() - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: mcpRegistry.Namespace, - Labels: labelsForRegistryAPI(mcpRegistry, secretName), - }, - Type: corev1.SecretTypeOpaque, - Data: map[string][]byte{ - pgpassSecretKey: []byte(pgpassContent), - }, - } - - // Upsert the secret with owner reference for garbage collection - if _, err := m.kubeHelper.Secrets.UpsertWithOwnerReference(ctx, secret, mcpRegistry); err != nil { - return fmt.Errorf("failed to upsert pgpass secret %s: %w", secretName, err) - } - - return nil -} diff --git a/cmd/thv-operator/pkg/registryapi/pgpass_test.go b/cmd/thv-operator/pkg/registryapi/pgpass_test.go deleted file mode 100644 index f2cb0045c9..0000000000 --- a/cmd/thv-operator/pkg/registryapi/pgpass_test.go +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. -// SPDX-License-Identifier: Apache-2.0 - -package registryapi - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" - "github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes" -) - -func TestEnsurePGPassSecret(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - mcpRegistry *mcpv1alpha1.MCPRegistry - existingObjs []client.Object - setupClient func(*testing.T, []client.Object) client.Client - expectedError string - validate func(*testing.T, client.Client, *mcpv1alpha1.MCPRegistry) - }{ - { - name: "successfully creates pgpass secret with correct content format", - mcpRegistry: baseMCPRegistry(t), - existingObjs: standardPasswordSecrets(), - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - //nolint:thelper // We want to see these lines in the test output - validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) { - secret := getPGPassSecret(t, c, mcpRegistry) - secretName := mcpRegistry.BuildPGPassSecretName() - - // Verify secret metadata - assert.Equal(t, secretName, secret.Name) - assert.Equal(t, "test-namespace", secret.Namespace) - assert.Equal(t, corev1.SecretTypeOpaque, secret.Type) - - // Verify labels - assert.Equal(t, secretName, secret.Labels["app.kubernetes.io/name"]) - assert.Equal(t, "registry-api", secret.Labels["app.kubernetes.io/component"]) - assert.Equal(t, "toolhive-operator", secret.Labels["app.kubernetes.io/managed-by"]) - assert.Equal(t, "test-registry", secret.Labels["toolhive.stacklok.io/registry-name"]) - - // Verify owner reference - require.Len(t, secret.OwnerReferences, 1) - assert.Equal(t, mcpRegistry.Name, secret.OwnerReferences[0].Name) - assert.Equal(t, "MCPRegistry", secret.OwnerReferences[0].Kind) - - // Verify pgpass content format - pgpassContent := string(secret.Data[".pgpass"]) - expectedContent := "postgres.example.com:5432:test_db:app_user:app_password_123\n" + - "postgres.example.com:5432:test_db:migration_user:migration_password_456\n" - assert.Equal(t, expectedContent, pgpassContent, "pgpass content should have correct format") - }, - }, - { - name: "uses default port 5432 when port is 0", - mcpRegistry: baseMCPRegistry(t, withPort(0)), - existingObjs: standardPasswordSecrets(), - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - //nolint:thelper // We want to see these lines in the test output - validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) { - secret := getPGPassSecret(t, c, mcpRegistry) - pgpassContent := string(secret.Data[".pgpass"]) - assert.Contains(t, pgpassContent, ":5432:", "Should use default port 5432 when port is 0") - }, - }, - { - name: "uses custom port when specified", - mcpRegistry: baseMCPRegistry(t, withPort(9999)), - existingObjs: standardPasswordSecrets(), - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - //nolint:thelper // We want to see these lines in the test output - validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) { - secret := getPGPassSecret(t, c, mcpRegistry) - pgpassContent := string(secret.Data[".pgpass"]) - assert.Contains(t, pgpassContent, ":9999:", "Should use custom port 9999") - }, - }, - { - name: "returns error when app user password secret read fails", - mcpRegistry: baseMCPRegistry(t), - existingObjs: []client.Object{ - // Only migration secret exists, app secret is missing - createPasswordSecret("migration-secret", "password", "migration_password_456"), - }, - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - expectedError: "failed to read app user password from secret app-secret", - }, - { - name: "returns error when app user password secret key does not exist", - mcpRegistry: baseMCPRegistry(t), - existingObjs: []client.Object{ - // App secret exists but with wrong key - createPasswordSecret("app-secret", "wrong-key", "app_password_123"), - createPasswordSecret("migration-secret", "password", "migration_password_456"), - }, - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - expectedError: "failed to read app user password from secret app-secret", - }, - { - name: "returns error when migration user password secret read fails", - mcpRegistry: baseMCPRegistry(t), - existingObjs: []client.Object{ - // Only app secret exists, migration secret is missing - createPasswordSecret("app-secret", "password", "app_password_123"), - }, - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - expectedError: "failed to read migration user password from secret migration-secret", - }, - { - name: "returns error when upsert fails", - mcpRegistry: baseMCPRegistry(t), - existingObjs: standardPasswordSecrets(), - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - // Create a client that fails when creating the pgpass secret - return fake.NewClientBuilder(). - WithScheme(createTestScheme()). - WithObjects(objs...). - WithInterceptorFuncs(interceptor.Funcs{ - Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { - if secret, ok := obj.(*corev1.Secret); ok { - // Fail only for the pgpass secret - if secret.Name == "test-registry-db-pgpass" { - return errors.New("simulated create failure: permission denied") - } - } - return c.Create(ctx, obj, opts...) - }, - }).Build() - }, - expectedError: "failed to upsert pgpass secret test-registry-db-pgpass", - }, - { - name: "verifies pgpass content format with special characters in password", - mcpRegistry: baseMCPRegistry(t, - withHost("db.prod.example.com"), - withDatabase("prod_registry"), - withUser("app_prod"), - withMigrationUser("migrator_prod"), - ), - existingObjs: []client.Object{ - // Passwords with special characters - createPasswordSecret("app-secret", "password", "p@ssw0rd!#$%"), - createPasswordSecret("migration-secret", "password", "migr@t0r&*()_+"), - }, - setupClient: func(t *testing.T, objs []client.Object) client.Client { - t.Helper() - return fake.NewClientBuilder().WithScheme(createTestScheme()).WithObjects(objs...).Build() - }, - //nolint:thelper // We want to see these lines in the test output - validate: func(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) { - secret := getPGPassSecret(t, c, mcpRegistry) - - // Verify the exact pgpass format: hostname:port:database:username:password - pgpassContent := string(secret.Data[".pgpass"]) - expectedLine1 := "db.prod.example.com:5432:prod_registry:app_prod:p@ssw0rd!#$%\n" - expectedLine2 := "db.prod.example.com:5432:prod_registry:migrator_prod:migr@t0r&*()_+\n" - expectedContent := expectedLine1 + expectedLine2 - - assert.Equal(t, expectedContent, pgpassContent, - "pgpass content should have correct format with both user entries and special characters") - - // Verify it contains exactly two lines - lines := []byte(pgpassContent) - lineCount := 0 - for _, b := range lines { - if b == '\n' { - lineCount++ - } - } - assert.Equal(t, 2, lineCount, "pgpass content should have exactly 2 lines") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // Create fake client with existing objects - fakeClient := tt.setupClient(t, tt.existingObjs) - - // Create scheme - scheme := createTestScheme() - - // Create manager - m := &manager{ - client: fakeClient, - scheme: scheme, - kubeHelper: kubernetes.NewClient(fakeClient, scheme), - } - - // Execute - err := m.ensurePGPassSecret(context.Background(), tt.mcpRegistry) - - // Verify - if tt.expectedError != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.expectedError) - } else { - require.NoError(t, err) - if tt.validate != nil { - tt.validate(t, fakeClient, tt.mcpRegistry) - } - } - }) - } -} - -// baseMCPRegistry creates a base MCPRegistry for testing with sensible defaults. -// Use functional options to customize specific fields. -func baseMCPRegistry(t *testing.T, opts ...func(*mcpv1alpha1.MCPRegistry)) *mcpv1alpha1.MCPRegistry { - t.Helper() - reg := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - Namespace: "test-namespace", - UID: types.UID("test-uid"), - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - DatabaseConfig: &mcpv1alpha1.MCPRegistryDatabaseConfig{ - Host: "postgres.example.com", - Port: 5432, - Database: "test_db", - User: "app_user", - MigrationUser: "migration_user", - DBAppUserPasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "app-secret"}, - Key: "password", - }, - DBMigrationUserPasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "migration-secret"}, - Key: "password", - }, - }, - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - {Name: "default", Format: mcpv1alpha1.RegistryFormatToolHive}, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - {Name: "default", Sources: []string{"default"}}, - }, - }, - } - for _, opt := range opts { - opt(reg) - } - return reg -} - -//nolint:staticcheck // Legacy test helpers intentionally use deprecated fields -func withPort(port int32) func(*mcpv1alpha1.MCPRegistry) { - return func(r *mcpv1alpha1.MCPRegistry) { r.Spec.DatabaseConfig.Port = port } -} - -//nolint:staticcheck // Legacy test helpers intentionally use deprecated fields -func withHost(host string) func(*mcpv1alpha1.MCPRegistry) { - return func(r *mcpv1alpha1.MCPRegistry) { r.Spec.DatabaseConfig.Host = host } -} - -//nolint:staticcheck // Legacy test helpers intentionally use deprecated fields -func withDatabase(db string) func(*mcpv1alpha1.MCPRegistry) { - return func(r *mcpv1alpha1.MCPRegistry) { r.Spec.DatabaseConfig.Database = db } -} - -//nolint:staticcheck // Legacy test helpers intentionally use deprecated fields -func withUser(user string) func(*mcpv1alpha1.MCPRegistry) { - return func(r *mcpv1alpha1.MCPRegistry) { r.Spec.DatabaseConfig.User = user } -} - -//nolint:staticcheck // Legacy test helpers intentionally use deprecated fields -func withMigrationUser(user string) func(*mcpv1alpha1.MCPRegistry) { - return func(r *mcpv1alpha1.MCPRegistry) { r.Spec.DatabaseConfig.MigrationUser = user } -} - -// standardPasswordSecrets creates the standard app and migration password secrets for testing. -func standardPasswordSecrets() []client.Object { - return []client.Object{ - createPasswordSecret("app-secret", "password", "app_password_123"), - createPasswordSecret("migration-secret", "password", "migration_password_456"), - } -} - -// createPasswordSecret creates a secret with a single key-value pair. -func createPasswordSecret(name, key, value string) *corev1.Secret { - return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: "test-namespace", - }, - Data: map[string][]byte{ - key: []byte(value), - }, - } -} - -// getPGPassSecret retrieves and returns the pgpass secret for the given MCPRegistry. -func getPGPassSecret(t *testing.T, c client.Client, mcpRegistry *mcpv1alpha1.MCPRegistry) *corev1.Secret { - t.Helper() - secret := &corev1.Secret{} - err := c.Get(context.Background(), types.NamespacedName{ - Name: mcpRegistry.BuildPGPassSecretName(), - Namespace: mcpRegistry.Namespace, - }, secret) - require.NoError(t, err, "pgpass secret should have been created") - return secret -} diff --git a/cmd/thv-operator/pkg/registryapi/podtemplatespec.go b/cmd/thv-operator/pkg/registryapi/podtemplatespec.go index 3fe31c46d6..bce605347c 100644 --- a/cmd/thv-operator/pkg/registryapi/podtemplatespec.go +++ b/cmd/thv-operator/pkg/registryapi/podtemplatespec.go @@ -15,7 +15,6 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" ) @@ -173,29 +172,6 @@ func WithRegistryServerConfigMount(containerName, configMapName string) PodTempl } } -// WithRegistryStorageMount creates an emptyDir volume and mount for registry storage. -// This adds both the emptyDir volume and the corresponding volume mount to the specified container. -func WithRegistryStorageMount(containerName string) PodTemplateSpecOption { - return func(pts *corev1.PodTemplateSpec) { - storageVolumeName := "storage-data" - - // Add the emptyDir volume - WithVolume(corev1.Volume{ - Name: storageVolumeName, - VolumeSource: corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{}, - }, - })(pts) - - // Add the volume mount - WithVolumeMount(containerName, corev1.VolumeMount{ - Name: storageVolumeName, - MountPath: "/data", - ReadOnly: false, - })(pts) - } -} - // WithInitContainer adds an init container to the PodSpec. // If an init container with the same name already exists, it is replaced for idempotency. func WithInitContainer(container corev1.Container) PodTemplateSpecOption { @@ -228,24 +204,6 @@ func WithEnvVar(containerName string, envVar corev1.EnvVar) PodTemplateSpecOptio } } -// WithPGPassMount configures the pgpass secret mounting for PostgreSQL authentication -// using an operator-generated secret. It constructs the secret volume from the given -// secret name and operator-defined key, then delegates to withPGPassMountFromVolume. -func WithPGPassMount(containerName, secretName string) PodTemplateSpecOption { - secretVolume := corev1.Volume{ - Name: PGPassSecretVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: secretName, - Items: []corev1.KeyToPath{ - {Key: GetPGPassSecretKey(), Path: pgpassFileName}, - }, - }, - }, - } - return withPGPassMountFromVolume(containerName, secretVolume) -} - // WithPGPassSecretRefMount configures pgpass secret mounting for PostgreSQL authentication // using a user-provided SecretKeySelector. If the secret reference is incomplete (empty // name or key), a no-op option is returned. Otherwise it constructs the secret volume @@ -356,104 +314,6 @@ func withPGPassMountFromVolume(containerName string, secretVolume corev1.Volume) } } -// WithGitAuthMount configures secret mounting for Git authentication. -// Unlike pgpass, Git credentials don't require special file permissions (0600), -// so no init container is needed - the secret is mounted directly. -// -// This function adds: -// 1. A volume from the secret containing the password/token -// 2. A volume mount to the specified container at /secrets/{secretName}/ -// -// The mount path matches what buildGitPasswordFilePath() generates in the config, -// ensuring the registry server can find the password file at the expected location. -// -// Volume naming uses the pattern "git-auth-{secretName}". If multiple registries -// reference the same secret, the volume and mount are idempotent - only one volume -// will be created due to the idempotency check in WithVolume. -// -// Parameters: -// - containerName: The name of the container to add the mount to -// - secretRef: The secret key selector referencing the password secret -func WithGitAuthMount(containerName string, secretRef corev1.SecretKeySelector) PodTemplateSpecOption { - return func(pts *corev1.PodTemplateSpec) { - // Both Name and Key are validated as required by buildGitAuthConfig() - if secretRef.Name == "" || secretRef.Key == "" { - return - } - - // Create a unique volume name based on the secret name - volumeName := fmt.Sprintf("git-auth-%s", secretRef.Name) - - // Add the secret volume - // Mount the specific key as a file - WithVolume(corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: secretRef.Name, - Items: []corev1.KeyToPath{ - { - Key: secretRef.Key, - Path: secretRef.Key, - }, - }, - }, - }, - })(pts) - - // Add the volume mount at /secrets/{secretName}/ - // This matches the path generated by buildGitPasswordFilePath() - mountPath := filepath.Join(gitAuthSecretsBasePath, secretRef.Name) - WithVolumeMount(containerName, corev1.VolumeMount{ - Name: volumeName, - MountPath: mountPath, - ReadOnly: true, - })(pts) - } -} - -// WithRegistrySourceMounts creates volumes and mounts for all registry sources. -// Each ConfigMap source gets its own volume and mount point -// at /config/registry/{sourceName}/. -func WithRegistrySourceMounts(containerName string, sources []mcpv1alpha1.MCPRegistrySourceConfig) PodTemplateSpecOption { - return func(pts *corev1.PodTemplateSpec) { - for _, source := range sources { - if source.ConfigMapRef != nil { - // ConfigMap: Create unique volume per source - volumeName := fmt.Sprintf("registry-data-source-%s", source.Name) - - // Add the ConfigMap volume - WithVolume(corev1.Volume{ - Name: volumeName, - VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: source.ConfigMapRef.Name, - }, - // Mount only the specified key as registry.json - Items: []corev1.KeyToPath{ - { - Key: source.ConfigMapRef.Key, - Path: "registry.json", - }, - }, - }, - }, - })(pts) - - // Add the volume mount at source-specific subdirectory - mountPath := filepath.Join(config.RegistryJSONFilePath, source.Name) - WithVolumeMount(containerName, corev1.VolumeMount{ - Name: volumeName, - MountPath: mountPath, - ReadOnly: true, - })(pts) - } - - } - } -} - // ParsePodTemplateSpec parses a runtime.RawExtension into a PodTemplateSpec. // Returns nil if the raw extension is nil or empty. // Returns an error if the raw extension contains invalid PodTemplateSpec data. diff --git a/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go b/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go index 7ee19a2213..4dfafdd0b3 100644 --- a/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go +++ b/cmd/thv-operator/pkg/registryapi/podtemplatespec_test.go @@ -4,7 +4,6 @@ package registryapi import ( - "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -12,7 +11,6 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" "github.com/stacklok/toolhive/cmd/thv-operator/pkg/registryapi/config" ) @@ -217,88 +215,6 @@ func TestRegistryMountOptions(t *testing.T) { assert.Equal(t, config.RegistryServerConfigFilePath, pts.Spec.Containers[0].VolumeMounts[0].MountPath) }, }, - // WithRegistryStorageMount tests - { - name: "WithRegistryStorageMount adds emptyDir volume and volume mount", - options: func() []PodTemplateSpecOption { - return []PodTemplateSpecOption{ - WithContainer(corev1.Container{Name: "registry-api"}), - WithRegistryStorageMount("registry-api"), - } - }, - assertions: func(t *testing.T, pts corev1.PodTemplateSpec) { - t.Helper() - require.Len(t, pts.Spec.Volumes, 1) - assert.Equal(t, "storage-data", pts.Spec.Volumes[0].Name) - assert.NotNil(t, pts.Spec.Volumes[0].EmptyDir) - - require.Len(t, pts.Spec.Containers, 1) - require.Len(t, pts.Spec.Containers[0].VolumeMounts, 1) - assert.Equal(t, "storage-data", pts.Spec.Containers[0].VolumeMounts[0].Name) - assert.Equal(t, "/data", pts.Spec.Containers[0].VolumeMounts[0].MountPath) - }, - }, - // WithRegistrySourceMounts tests - { - name: "WithRegistrySourceMounts adds mounts for registries with ConfigMapRef", - options: func() []PodTemplateSpecOption { - sources := []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "reg1", - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "configmap1", - }, - Key: "data.json", - }, - }, - { - Name: "reg2", - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "configmap2", - }, - Key: "registry.json", - }, - }, - } - return []PodTemplateSpecOption{ - WithContainer(corev1.Container{Name: "registry-api"}), - WithRegistrySourceMounts("registry-api", sources), - } - }, - assertions: func(t *testing.T, pts corev1.PodTemplateSpec) { - t.Helper() - assert.Len(t, pts.Spec.Volumes, 2) - require.Len(t, pts.Spec.Containers, 1) - assert.Len(t, pts.Spec.Containers[0].VolumeMounts, 2) - assert.Equal(t, "registry-data-source-reg1", pts.Spec.Containers[0].VolumeMounts[0].Name) - assert.Equal(t, "registry-data-source-reg2", pts.Spec.Containers[0].VolumeMounts[1].Name) - assert.Equal(t, filepath.Join(config.RegistryJSONFilePath, "reg1"), pts.Spec.Containers[0].VolumeMounts[0].MountPath) - assert.Equal(t, filepath.Join(config.RegistryJSONFilePath, "reg2"), pts.Spec.Containers[0].VolumeMounts[1].MountPath) - }, - }, - { - name: "WithRegistrySourceMounts skips registries without ConfigMapRef", - options: func() []PodTemplateSpecOption { - sources := []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "reg1", - ConfigMapRef: nil, - }, - } - return []PodTemplateSpecOption{ - WithContainer(corev1.Container{Name: "registry-api"}), - WithRegistrySourceMounts("registry-api", sources), - } - }, - assertions: func(t *testing.T, pts corev1.PodTemplateSpec) { - t.Helper() - assert.Empty(t, pts.Spec.Volumes) - require.Len(t, pts.Spec.Containers, 1) - assert.Empty(t, pts.Spec.Containers[0].VolumeMounts) - }, - }, } for _, tt := range tests { @@ -811,394 +727,6 @@ func TestNewPodTemplateSpecBuilderFrom_MergeOnBuild(t *testing.T) { }) } -func TestWithPGPassMount(t *testing.T) { - t.Parallel() - - t.Run("adds secret volume for pgpass", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - // Find the secret volume - var secretVolume *corev1.Volume - for i := range pts.Spec.Volumes { - if pts.Spec.Volumes[i].Name == "pgpass-secret" { - secretVolume = &pts.Spec.Volumes[i] - break - } - } - - require.NotNil(t, secretVolume, "pgpass-secret volume should exist") - require.NotNil(t, secretVolume.Secret) - assert.Equal(t, "my-pgpass-secret", secretVolume.Secret.SecretName) - require.Len(t, secretVolume.Secret.Items, 1) - assert.Equal(t, ".pgpass", secretVolume.Secret.Items[0].Key) - assert.Equal(t, ".pgpass", secretVolume.Secret.Items[0].Path) - }) - - t.Run("adds emptyDir volume for prepared pgpass", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - // Find the emptyDir volume - var emptyDirVolume *corev1.Volume - for i := range pts.Spec.Volumes { - if pts.Spec.Volumes[i].Name == "pgpass" { - emptyDirVolume = &pts.Spec.Volumes[i] - break - } - } - - require.NotNil(t, emptyDirVolume, "pgpass emptyDir volume should exist") - require.NotNil(t, emptyDirVolume.EmptyDir) - }) - - t.Run("adds init container with correct command", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - require.Len(t, pts.Spec.InitContainers, 1) - initContainer := pts.Spec.InitContainers[0] - - assert.Equal(t, "setup-pgpass", initContainer.Name) - assert.Equal(t, "cgr.dev/chainguard/busybox:latest", initContainer.Image) - - // Verify command structure - require.Len(t, initContainer.Command, 3) - assert.Equal(t, "sh", initContainer.Command[0]) - assert.Equal(t, "-c", initContainer.Command[1]) - // Command should copy file and chmod 600 (no chown needed - Chainguard image runs as 65532) - assert.Contains(t, initContainer.Command[2], "cp /secret/.pgpass /pgpass/.pgpass") - assert.Contains(t, initContainer.Command[2], "chmod 0600 /pgpass/.pgpass") - }) - - t.Run("init container has correct volume mounts", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - require.Len(t, pts.Spec.InitContainers, 1) - initContainer := pts.Spec.InitContainers[0] - - require.Len(t, initContainer.VolumeMounts, 2) - - // Find secret mount - var secretMount, emptyDirMount *corev1.VolumeMount - for i := range initContainer.VolumeMounts { - switch initContainer.VolumeMounts[i].Name { - case "pgpass-secret": - secretMount = &initContainer.VolumeMounts[i] - case "pgpass": - emptyDirMount = &initContainer.VolumeMounts[i] - } - } - - require.NotNil(t, secretMount, "secret volume mount should exist") - assert.Equal(t, "/secret", secretMount.MountPath) - assert.True(t, secretMount.ReadOnly) - - require.NotNil(t, emptyDirMount, "emptyDir volume mount should exist") - assert.Equal(t, "/pgpass", emptyDirMount.MountPath) - assert.False(t, emptyDirMount.ReadOnly) - }) - - t.Run("adds volume mount to app container with subPath", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - require.Len(t, pts.Spec.Containers, 1) - container := pts.Spec.Containers[0] - - require.Len(t, container.VolumeMounts, 1) - mount := container.VolumeMounts[0] - - assert.Equal(t, "pgpass", mount.Name) - assert.Equal(t, "/home/appuser/.pgpass", mount.MountPath) - assert.Equal(t, ".pgpass", mount.SubPath) - assert.True(t, mount.ReadOnly) - }) - - t.Run("adds PGPASSFILE environment variable", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - require.Len(t, pts.Spec.Containers, 1) - container := pts.Spec.Containers[0] - - var pgpassfileEnv *corev1.EnvVar - for i := range container.Env { - if container.Env[i].Name == pgpassEnvVar { - pgpassfileEnv = &container.Env[i] - break - } - } - - require.NotNil(t, pgpassfileEnv, "PGPASSFILE env var should exist") - assert.Equal(t, "/home/appuser/.pgpass", pgpassfileEnv.Value) - }) - - t.Run("does nothing if container not found", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithPGPassMount("nonexistent-container", "my-pgpass-secret"), - ).Build() - - // Volumes and init container are still added - assert.Len(t, pts.Spec.Volumes, 2) - assert.Len(t, pts.Spec.InitContainers, 1) - // But no app containers - assert.Empty(t, pts.Spec.Containers) - }) - - t.Run("volumes and env vars are idempotent when called multiple times", func(t *testing.T) { - t.Parallel() - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: "registry-api"}), - WithPGPassMount("registry-api", "my-pgpass-secret"), - WithPGPassMount("registry-api", "my-pgpass-secret"), - ).Build() - - // Volumes are idempotent - should only have 2 volumes (pgpass-secret and pgpass) - assert.Len(t, pts.Spec.Volumes, 2) - // Volume mounts are idempotent - should only have 1 volume mount in app container - require.Len(t, pts.Spec.Containers, 1) - assert.Len(t, pts.Spec.Containers[0].VolumeMounts, 1) - // Env vars are idempotent - should only have 1 PGPASSFILE env var - pgpassCount := 0 - for _, env := range pts.Spec.Containers[0].Env { - if env.Name == pgpassEnvVar { - pgpassCount++ - } - } - assert.Equal(t, 1, pgpassCount) - // Init containers are idempotent - should only have 1 init container - assert.Len(t, pts.Spec.InitContainers, 1) - }) -} - -func TestWithGitAuthMount(t *testing.T) { - t.Parallel() - - // Test constants - const ( - testSecretName = "git-credentials" - testVolumeName = "git-auth-git-credentials" - testContainerName = "registry-api" - testExpectedMountPath = "/secrets/git-credentials" - ) - - t.Run("adds secret volume for git auth", func(t *testing.T) { - t.Parallel() - - secretRef := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: "token", - } - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: testContainerName}), - WithGitAuthMount(testContainerName, secretRef), - ).Build() - - // Find the secret volume - var secretVolume *corev1.Volume - for i := range pts.Spec.Volumes { - if pts.Spec.Volumes[i].Name == testVolumeName { - secretVolume = &pts.Spec.Volumes[i] - break - } - } - - require.NotNil(t, secretVolume, "git-auth volume should exist") - require.NotNil(t, secretVolume.Secret) - assert.Equal(t, testSecretName, secretVolume.Secret.SecretName) - require.Len(t, secretVolume.Secret.Items, 1) - assert.Equal(t, "token", secretVolume.Secret.Items[0].Key) - assert.Equal(t, "token", secretVolume.Secret.Items[0].Path) - }) - - t.Run("adds volume mount at correct path", func(t *testing.T) { - t.Parallel() - - secretRef := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: "token", - } - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: testContainerName}), - WithGitAuthMount(testContainerName, secretRef), - ).Build() - - require.Len(t, pts.Spec.Containers, 1) - container := pts.Spec.Containers[0] - - // Find the volume mount - var volumeMount *corev1.VolumeMount - for i := range container.VolumeMounts { - if container.VolumeMounts[i].Name == testVolumeName { - volumeMount = &container.VolumeMounts[i] - break - } - } - - require.NotNil(t, volumeMount, "git-auth volume mount should exist") - assert.Equal(t, testExpectedMountPath, volumeMount.MountPath) - assert.True(t, volumeMount.ReadOnly) - }) - - t.Run("does nothing when key is empty", func(t *testing.T) { - t.Parallel() - - secretRef := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - // Key is empty - should be skipped (key is required) - } - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: testContainerName}), - WithGitAuthMount(testContainerName, secretRef), - ).Build() - - // No volume should be added when key is empty - for _, vol := range pts.Spec.Volumes { - assert.NotContains(t, vol.Name, "git-auth", "no git-auth volume should be added when key is empty") - } - }) - - t.Run("does nothing when secret name is empty", func(t *testing.T) { - t.Parallel() - - secretRef := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "", // Empty name should be skipped - }, - Key: "token", - } - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: testContainerName}), - WithGitAuthMount(testContainerName, secretRef), - ).Build() - - // No volumes should be added - assert.Empty(t, pts.Spec.Volumes) - // No volume mounts on container - require.Len(t, pts.Spec.Containers, 1) - assert.Empty(t, pts.Spec.Containers[0].VolumeMounts) - }) - - t.Run("supports multiple git auth secrets", func(t *testing.T) { - t.Parallel() - - const ( - secretName1 = "git-credentials-1" - secretName2 = "git-credentials-2" - ) - - secretRef1 := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretName1, - }, - Key: "token", - } - secretRef2 := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretName2, - }, - Key: "password", - } - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: testContainerName}), - WithGitAuthMount(testContainerName, secretRef1), - WithGitAuthMount(testContainerName, secretRef2), - ).Build() - - // Should have 2 volumes - assert.Len(t, pts.Spec.Volumes, 2) - - // Should have 2 volume mounts - require.Len(t, pts.Spec.Containers, 1) - assert.Len(t, pts.Spec.Containers[0].VolumeMounts, 2) - - // Verify mount paths - mountPaths := make(map[string]bool) - for _, mount := range pts.Spec.Containers[0].VolumeMounts { - mountPaths[mount.MountPath] = true - } - assert.True(t, mountPaths["/secrets/"+secretName1]) - assert.True(t, mountPaths["/secrets/"+secretName2]) - }) - - t.Run("volumes are idempotent when called multiple times with same secret", func(t *testing.T) { - t.Parallel() - - secretRef := corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: testSecretName, - }, - Key: "token", - } - - builder := NewPodTemplateSpecBuilderFrom(nil) - pts := builder.Apply( - WithContainer(corev1.Container{Name: testContainerName}), - WithGitAuthMount(testContainerName, secretRef), - WithGitAuthMount(testContainerName, secretRef), - ).Build() - - // Volumes are idempotent - should only have 1 volume - assert.Len(t, pts.Spec.Volumes, 1) - // Volume mounts are idempotent - should only have 1 volume mount - require.Len(t, pts.Spec.Containers, 1) - assert.Len(t, pts.Spec.Containers[0].VolumeMounts, 1) - }) -} - func TestWithPGPassSecretRefMount(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/pkg/registryapi/rbac_test.go b/cmd/thv-operator/pkg/registryapi/rbac_test.go index bd0ae2115e..4e65f371fd 100644 --- a/cmd/thv-operator/pkg/registryapi/rbac_test.go +++ b/cmd/thv-operator/pkg/registryapi/rbac_test.go @@ -30,22 +30,7 @@ func createTestMCPRegistry() *mcpv1alpha1.MCPRegistry { UID: types.UID("test-uid"), }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: "test-configmap"}, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, + ConfigYAML: "sources:\n - name: default\n format: toolhive\nregistries:\n - name: default\n sources: [\"default\"]\n", }, } } diff --git a/cmd/thv-operator/pkg/registryapi/types.go b/cmd/thv-operator/pkg/registryapi/types.go index fec54a358e..608c1ef7ff 100644 --- a/cmd/thv-operator/pkg/registryapi/types.go +++ b/cmd/thv-operator/pkg/registryapi/types.go @@ -42,15 +42,9 @@ const ( // ReadinessPeriod is the period in seconds for readiness probe checks ReadinessPeriod = 5 - // RegistryDataVolumeName is the name of the volume used for registry data - RegistryDataVolumeName = "registry-data" - // RegistryServerConfigVolumeName is the name of the volume used for registry server config RegistryServerConfigVolumeName = "registry-server-config" - // RegistryDataMountPath is the mount path for registry data in containers - RegistryDataMountPath = "/data/registry" - // ServeCommand is the command used to start the registry API server ServeCommand = "serve" @@ -86,10 +80,6 @@ const ( pgpassFileName = ".pgpass" // pgpassEnvVar is the environment variable name for the pgpass file path pgpassEnvVar = "PGPASSFILE" - - // Git auth volume and path constants - // gitAuthSecretsBasePath is the base path where git auth secrets are mounted - gitAuthSecretsBasePath = "/secrets" ) // Error represents a structured error with condition information for operator components diff --git a/cmd/thv-operator/pkg/registryapi/types_test.go b/cmd/thv-operator/pkg/registryapi/types_test.go index be079a70ba..6c9c9af3ff 100644 --- a/cmd/thv-operator/pkg/registryapi/types_test.go +++ b/cmd/thv-operator/pkg/registryapi/types_test.go @@ -66,50 +66,6 @@ func TestLabelsForRegistryAPI(t *testing.T) { } } -// TestGetConfigMapName tests ConfigMap name generation -func TestGetConfigMapName(t *testing.T) { - t.Parallel() - tests := []struct { - name string - mcpRegistry *mcpv1alpha1.MCPRegistry - expected string - description string - }{ - { - name: "BasicConfigMapName", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-registry", - }, - }, - expected: "test-registry-registry-storage", - description: "Should generate correct ConfigMap name for basic registry", - }, - { - name: "ConfigMapNameWithSpecialChars", - mcpRegistry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-special-registry-123", - }, - }, - expected: "my-special-registry-123-registry-storage", - description: "Should handle special characters in registry name", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - result := getConfigMapName(tt.mcpRegistry) - assert.Equal(t, tt.expected, result, tt.description) - - // Also verify it matches the MCPRegistry helper method - assert.Equal(t, tt.mcpRegistry.GetStorageName(), result, - "getConfigMapName should match MCPRegistry.GetStorageName()") - }) - } -} - // TestMCPRegistryHelperMethods tests the helper methods on MCPRegistry type func TestMCPRegistryHelperMethods(t *testing.T) { t.Parallel() diff --git a/cmd/thv-operator/test-integration/mcp-registry/deployment_update_test.go b/cmd/thv-operator/test-integration/mcp-registry/deployment_update_test.go index 617e7f98ef..2d04448834 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/deployment_update_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/deployment_update_test.go @@ -13,11 +13,8 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" - - mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" ) var _ = Describe("MCPRegistry Deployment Updates", Label("k8s", "registry", "deployment-update"), func() { @@ -164,34 +161,14 @@ var _ = Describe("MCPRegistry Deployment Updates", Label("k8s", "registry", "dep It("should update deployment when PodTemplateSpec imagePullSecrets changes", func() { By("creating a registry with initial imagePullSecrets") configMap := configMapHelper.CreateSampleToolHiveRegistry("update-change-ips-config") - registry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "update-change-ips-test", - Namespace: testNamespace, - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: configMap.Name}, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{Interval: "1h"}, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - PodTemplateSpec: &runtime.RawExtension{ - Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"creds-a"}]}}`), - }, - }, + registryObj := registryHelper.NewRegistryBuilder("update-change-ips-test"). + WithConfigMapSource(configMap.Name, "registry.json"). + WithSyncPolicy("1h"). + Build() + registryObj.Spec.PodTemplateSpec = &runtime.RawExtension{ + Raw: []byte(`{"spec":{"imagePullSecrets":[{"name":"creds-a"}]}}`), } + registry := registryObj Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) By("waiting for deployment with initial imagePullSecrets") @@ -251,22 +228,26 @@ var _ = Describe("MCPRegistry Deployment Updates", Label("k8s", "registry", "dep originalHash := deployment.Spec.Template.Annotations["toolhive.stacklok.dev/config-hash"] Expect(originalHash).NotTo(BeEmpty(), "config-hash should be set on initial deployment") - By("creating a second ConfigMap and adding it as a registry source") - configMap2 := configMapHelper.CreateSampleToolHiveRegistry("spec-change-config-2") + By("updating the registry configYAML to include a second source") + _ = configMapHelper.CreateSampleToolHiveRegistry("spec-change-config-2") updatedRegistry, err := registryHelper.GetRegistry(registry.Name) Expect(err).NotTo(HaveOccurred()) - updatedRegistry.Spec.Sources = append(updatedRegistry.Spec.Sources, mcpv1alpha1.MCPRegistrySourceConfig{ - Name: "extra", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{Name: configMap2.Name}, - Key: "registry.json", + // Replace the configYAML with one that has two sources + updatedRegistry.Spec.ConfigYAML = buildConfigYAMLForMultipleSources([]map[string]string{ + { + "name": "default", + "sourceType": "file", + "filePath": "/config/registry/default/registry.json", + "interval": "1h", + }, + { + "name": "extra", + "sourceType": "file", + "filePath": "/config/registry/extra/registry.json", + "interval": "30m", }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{Interval: "30m"}, }) - // Also add the new source to the registry view - updatedRegistry.Spec.Registries[0].Sources = append(updatedRegistry.Spec.Registries[0].Sources, "extra") Expect(registryHelper.UpdateRegistry(updatedRegistry)).To(Succeed()) By("waiting for deployment config-hash to change") 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 8e87ae3cf5..5ddcbc10ac 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registry_helpers.go @@ -5,12 +5,15 @@ package operator_test import ( "context" + "encoding/json" "fmt" + "strings" "time" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -35,221 +38,195 @@ func NewMCPRegistryTestHelper(ctx context.Context, k8sClient client.Client, name } } +const ( + sourceTypeFile = "file" + sourceTypeGit = "git" + sourceTypeAPI = "api" +) + +// registryBuilderConfig holds the configuration data used to generate configYAML +type registryBuilderConfig struct { + SourceName string + Format string + SourceType string + FilePath string // for file sources: path inside the mounted volume + GitRepo string + GitBranch string + GitPath string + APIEndpoint string + SyncInterval string + NameInclude []string + NameExclude []string + TagInclude []string + TagExclude []string + // ConfigMap source details (for volume/mount generation) + ConfigMapName string + ConfigMapKey string +} + // RegistryBuilder provides a fluent interface for building MCPRegistry objects type RegistryBuilder struct { - registry *mcpv1alpha1.MCPRegistry + name string + namespace string + labels map[string]string + annotations map[string]string + config registryBuilderConfig } // NewRegistryBuilder creates a new MCPRegistry builder func (h *MCPRegistryTestHelper) NewRegistryBuilder(name string) *RegistryBuilder { return &RegistryBuilder{ - registry: &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: h.Namespace, - Labels: map[string]string{ - "test.toolhive.io/suite": "operator-e2e", - }, - }, - Spec: mcpv1alpha1.MCPRegistrySpec{}, + name: name, + namespace: h.Namespace, + labels: map[string]string{ + "test.toolhive.io/suite": "operator-e2e", + }, + config: registryBuilderConfig{ + SourceName: "default", + Format: "toolhive", }, } } -// ensureSourceConfig ensures there's at least one source config in the array -func (rb *RegistryBuilder) ensureSourceConfig() { - if len(rb.registry.Spec.Sources) == 0 { - rb.registry.Spec.Sources = []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - }, - } - } -} - -// ensureRegistryView ensures there's at least one registry view referencing the sources -func (rb *RegistryBuilder) ensureRegistryView() { - if len(rb.registry.Spec.Registries) == 0 { - rb.registry.Spec.Registries = []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - } - } -} - -// getCurrentSourceConfig returns the last source config in the array (for chaining) -func (rb *RegistryBuilder) getCurrentSourceConfig() *mcpv1alpha1.MCPRegistrySourceConfig { - rb.ensureSourceConfig() - return &rb.registry.Spec.Sources[len(rb.registry.Spec.Sources)-1] -} - -// WithConfigMapSource configures the registry with a ConfigMap source +// WithConfigMapSource configures the registry with a ConfigMap-backed file source. +// It sets source type to file and records ConfigMap details for volume/mount generation. func (rb *RegistryBuilder) WithConfigMapSource(configMapName, key string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - sourceConfig.ConfigMapRef = &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMapName, - }, - Key: key, - } - rb.ensureRegistryView() + rb.config.SourceType = sourceTypeFile + rb.config.ConfigMapName = configMapName + rb.config.ConfigMapKey = key + rb.config.FilePath = fmt.Sprintf("/config/registry/%s/registry.json", rb.config.SourceName) return rb } // WithGitSource configures the registry with a Git source func (rb *RegistryBuilder) WithGitSource(repository, branch, path string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - sourceConfig.Git = &mcpv1alpha1.GitSource{ - Repository: repository, - Branch: branch, - Path: path, - } - rb.ensureRegistryView() - return rb -} - -// WithGitAuth adds authentication configuration to the current Git source. -// This must be called after WithGitSource. -func (rb *RegistryBuilder) WithGitAuth(username, secretName, secretKey string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - if sourceConfig.Git == nil { - // Git source must be configured first - return rb - } - sourceConfig.Git.Auth = &mcpv1alpha1.GitAuthConfig{ - Username: username, - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: secretName, - }, - Key: secretKey, - }, - } + rb.config.SourceType = sourceTypeGit + rb.config.GitRepo = repository + rb.config.GitBranch = branch + rb.config.GitPath = path return rb } // WithAPISource configures the registry with an API source func (rb *RegistryBuilder) WithAPISource(endpoint string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - sourceConfig.API = &mcpv1alpha1.APISource{ - Endpoint: endpoint, - } - rb.ensureRegistryView() + rb.config.SourceType = sourceTypeAPI + rb.config.APIEndpoint = endpoint return rb } -// WithRegistryName sets the name for the current source config +// WithRegistryName sets the name for the source config func (rb *RegistryBuilder) WithRegistryName(name string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - sourceConfig.Name = name + rb.config.SourceName = name + // Recalculate file path if this is a file source + if rb.config.SourceType == sourceTypeFile { + rb.config.FilePath = fmt.Sprintf("/config/registry/%s/registry.json", name) + } return rb } // WithUpstreamFormat configures the source to use upstream MCP format func (rb *RegistryBuilder) WithUpstreamFormat() *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - sourceConfig.Format = mcpv1alpha1.RegistryFormatUpstream + rb.config.Format = "upstream" return rb } -// WithSyncPolicy configures the sync policy for the current source +// WithSyncPolicy configures the sync policy interval for the source func (rb *RegistryBuilder) WithSyncPolicy(interval string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - sourceConfig.SyncPolicy = &mcpv1alpha1.SyncPolicy{ - Interval: interval, - } + rb.config.SyncInterval = interval return rb } // WithAnnotation adds an annotation to the registry func (rb *RegistryBuilder) WithAnnotation(key, value string) *RegistryBuilder { - if rb.registry.Annotations == nil { - rb.registry.Annotations = make(map[string]string) + if rb.annotations == nil { + rb.annotations = make(map[string]string) } - rb.registry.Annotations[key] = value + rb.annotations[key] = value return rb } // WithLabel adds a label to the registry func (rb *RegistryBuilder) WithLabel(key, value string) *RegistryBuilder { - if rb.registry.Labels == nil { - rb.registry.Labels = make(map[string]string) + if rb.labels == nil { + rb.labels = make(map[string]string) } - rb.registry.Labels[key] = value + rb.labels[key] = value return rb } -// WithNameIncludeFilter sets name include patterns for filtering on the current source +// WithNameIncludeFilter sets name include patterns for filtering on the source func (rb *RegistryBuilder) WithNameIncludeFilter(patterns []string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - if sourceConfig.Filter == nil { - sourceConfig.Filter = &mcpv1alpha1.RegistryFilter{} - } - if sourceConfig.Filter.NameFilters == nil { - sourceConfig.Filter.NameFilters = &mcpv1alpha1.NameFilter{} - } - sourceConfig.Filter.NameFilters.Include = patterns + rb.config.NameInclude = patterns return rb } -// WithNameExcludeFilter sets name exclude patterns for filtering on the current source +// WithNameExcludeFilter sets name exclude patterns for filtering on the source func (rb *RegistryBuilder) WithNameExcludeFilter(patterns []string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - if sourceConfig.Filter == nil { - sourceConfig.Filter = &mcpv1alpha1.RegistryFilter{} - } - if sourceConfig.Filter.NameFilters == nil { - sourceConfig.Filter.NameFilters = &mcpv1alpha1.NameFilter{} - } - sourceConfig.Filter.NameFilters.Exclude = patterns + rb.config.NameExclude = patterns return rb } -// WithTagIncludeFilter sets tag include patterns for filtering on the current source +// WithTagIncludeFilter sets tag include patterns for filtering on the source func (rb *RegistryBuilder) WithTagIncludeFilter(tags []string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - if sourceConfig.Filter == nil { - sourceConfig.Filter = &mcpv1alpha1.RegistryFilter{} - } - if sourceConfig.Filter.Tags == nil { - sourceConfig.Filter.Tags = &mcpv1alpha1.TagFilter{} - } - sourceConfig.Filter.Tags.Include = tags + rb.config.TagInclude = tags return rb } -// WithTagExcludeFilter sets tag exclude patterns for filtering on the current source +// WithTagExcludeFilter sets tag exclude patterns for filtering on the source func (rb *RegistryBuilder) WithTagExcludeFilter(tags []string) *RegistryBuilder { - sourceConfig := rb.getCurrentSourceConfig() - if sourceConfig.Filter == nil { - sourceConfig.Filter = &mcpv1alpha1.RegistryFilter{} - } - if sourceConfig.Filter.Tags == nil { - sourceConfig.Filter.Tags = &mcpv1alpha1.TagFilter{} - } - sourceConfig.Filter.Tags.Exclude = tags + rb.config.TagExclude = tags return rb } -// Build returns the constructed MCPRegistry. -// It syncs the default registry view's source list with the actual source names. +// Build returns the constructed MCPRegistry with configYAML generated from the builder config. func (rb *RegistryBuilder) Build() *mcpv1alpha1.MCPRegistry { - rb.ensureSourceConfig() - rb.ensureRegistryView() - - // Sync the default registry view's source list with actual source names - if len(rb.registry.Spec.Registries) == 1 && rb.registry.Spec.Registries[0].Name == "default" { - sourceNames := make([]string, 0, len(rb.registry.Spec.Sources)) - for _, s := range rb.registry.Spec.Sources { - sourceNames = append(sourceNames, s.Name) + configYAML := rb.buildConfigYAML() + + spec := mcpv1alpha1.MCPRegistrySpec{ + ConfigYAML: configYAML, + } + + // For ConfigMap file sources, add the volume and volume mount + if rb.config.SourceType == sourceTypeFile && rb.config.ConfigMapName != "" { + vol := corev1.Volume{ + Name: fmt.Sprintf("registry-data-source-%s", rb.config.SourceName), + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: rb.config.ConfigMapName, + }, + Items: []corev1.KeyToPath{ + { + Key: rb.config.ConfigMapKey, + Path: "registry.json", + }, + }, + }, + }, + } + volJSON, err := json.Marshal(vol) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to marshal volume") + spec.Volumes = []apiextensionsv1.JSON{{Raw: volJSON}} + + mount := corev1.VolumeMount{ + Name: fmt.Sprintf("registry-data-source-%s", rb.config.SourceName), + MountPath: fmt.Sprintf("/config/registry/%s", rb.config.SourceName), + ReadOnly: true, } - rb.registry.Spec.Registries[0].Sources = sourceNames + mountJSON, err := json.Marshal(mount) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to marshal volume mount") + spec.VolumeMounts = []apiextensionsv1.JSON{{Raw: mountJSON}} } - return rb.registry.DeepCopy() + return &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: rb.name, + Namespace: rb.namespace, + Labels: rb.labels, + Annotations: rb.annotations, + }, + Spec: spec, + } } // Create builds and creates the MCPRegistry in the cluster @@ -260,6 +237,93 @@ func (rb *RegistryBuilder) Create(h *MCPRegistryTestHelper) *mcpv1alpha1.MCPRegi return registry } +// buildConfigYAML generates the config.yaml content from the builder config +func (rb *RegistryBuilder) buildConfigYAML() string { + var b strings.Builder + + // Sources section + b.WriteString("sources:\n") + fmt.Fprintf(&b, " - name: %s\n", rb.config.SourceName) + fmt.Fprintf(&b, " format: %s\n", rb.config.Format) + + // Source type specific fields + switch rb.config.SourceType { + case sourceTypeFile: + b.WriteString(" file:\n") + fmt.Fprintf(&b, " path: %s\n", rb.config.FilePath) + case sourceTypeGit: + b.WriteString(" git:\n") + fmt.Fprintf(&b, " repository: %s\n", rb.config.GitRepo) + fmt.Fprintf(&b, " branch: %s\n", rb.config.GitBranch) + fmt.Fprintf(&b, " path: %s\n", rb.config.GitPath) + case sourceTypeAPI: + b.WriteString(" api:\n") + fmt.Fprintf(&b, " endpoint: %s\n", rb.config.APIEndpoint) + } + + // Sync policy + if rb.config.SyncInterval != "" { + b.WriteString(" syncPolicy:\n") + fmt.Fprintf(&b, " interval: %s\n", rb.config.SyncInterval) + } + + // Filter + rb.writeFilterYAML(&b) + + // Registries section + b.WriteString("registries:\n") + b.WriteString(" - name: default\n") + fmt.Fprintf(&b, " sources:\n - %s\n", rb.config.SourceName) + + // Database defaults + b.WriteString("database:\n") + b.WriteString(" host: postgres\n") + b.WriteString(" port: 5432\n") + b.WriteString(" user: db_app\n") + b.WriteString(" database: registry\n") + + // Auth defaults + b.WriteString("auth:\n") + b.WriteString(" mode: anonymous\n") + + return b.String() +} + +// writeFilterYAML writes filter configuration to the YAML builder +func (rb *RegistryBuilder) writeFilterYAML(b *strings.Builder) { + hasNames := len(rb.config.NameInclude) > 0 || len(rb.config.NameExclude) > 0 + hasTags := len(rb.config.TagInclude) > 0 || len(rb.config.TagExclude) > 0 + + if !hasNames && !hasTags { + return + } + + b.WriteString(" filter:\n") + + if hasNames { + b.WriteString(" names:\n") + writeStringList(b, " include:\n", rb.config.NameInclude) + writeStringList(b, " exclude:\n", rb.config.NameExclude) + } + + if hasTags { + b.WriteString(" tags:\n") + writeStringList(b, " include:\n", rb.config.TagInclude) + writeStringList(b, " exclude:\n", rb.config.TagExclude) + } +} + +// writeStringList writes a labeled YAML list if items is non-empty +func writeStringList(b *strings.Builder, label string, items []string) { + if len(items) == 0 { + return + } + b.WriteString(label) + for _, item := range items { + fmt.Fprintf(b, " - %s\n", item) + } +} + // CreateBasicConfigMapRegistry creates a simple MCPRegistry with ConfigMap source func (h *MCPRegistryTestHelper) CreateBasicConfigMapRegistry(name, configMapName string) *mcpv1alpha1.MCPRegistry { return h.NewRegistryBuilder(name). @@ -428,3 +492,71 @@ func containsFinalizer(finalizers []string, _ string) bool { } return false } + +// buildConfigYAMLForMultipleSources generates a configYAML string for multiple sources. +// Each source is specified as a map with keys: name, format, sourceType, and type-specific fields. +func buildConfigYAMLForMultipleSources(sources []map[string]string) string { + var b strings.Builder + + b.WriteString("sources:\n") + for _, src := range sources { + fmt.Fprintf(&b, " - name: %s\n", src["name"]) + format := src["format"] + if format == "" { + format = "toolhive" + } + fmt.Fprintf(&b, " format: %s\n", format) + + switch src["sourceType"] { + case sourceTypeFile: + b.WriteString(" file:\n") + fmt.Fprintf(&b, " path: %s\n", src["filePath"]) + case sourceTypeGit: + b.WriteString(" git:\n") + fmt.Fprintf(&b, " repository: %s\n", src["repository"]) + fmt.Fprintf(&b, " branch: %s\n", src["branch"]) + fmt.Fprintf(&b, " path: %s\n", src["path"]) + if src["authUsername"] != "" { + b.WriteString(" auth:\n") + fmt.Fprintf(&b, " username: %s\n", src["authUsername"]) + fmt.Fprintf(&b, " passwordFile: %s\n", src["authPasswordFile"]) + } + case sourceTypeAPI: + b.WriteString(" api:\n") + fmt.Fprintf(&b, " endpoint: %s\n", src["endpoint"]) + } + + if interval, ok := src["interval"]; ok && interval != "" { + b.WriteString(" syncPolicy:\n") + fmt.Fprintf(&b, " interval: %s\n", interval) + } + } + + // Registries section with all source names + b.WriteString("registries:\n") + b.WriteString(" - name: default\n") + b.WriteString(" sources:\n") + for _, src := range sources { + fmt.Fprintf(&b, " - %s\n", src["name"]) + } + + // Database defaults + b.WriteString("database:\n") + b.WriteString(" host: postgres\n") + b.WriteString(" port: 5432\n") + b.WriteString(" user: db_app\n") + b.WriteString(" database: registry\n") + + // Auth defaults + b.WriteString("auth:\n") + b.WriteString(" mode: anonymous\n") + + return b.String() +} + +// mustMarshalJSON marshals a value to JSON, panicking on error (for test helpers only) +func mustMarshalJSON(v interface{}) []byte { + data, err := json.Marshal(v) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "Failed to marshal JSON in test helper") + return data +} diff --git a/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go b/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go index 00e9c9ad30..a42d6b220b 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registry_lifecycle_test.go @@ -186,11 +186,11 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f statusHelper.WaitForPhaseAny(registry1.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) statusHelper.WaitForPhaseAny(registry2.Name, []mcpv1alpha1.MCPRegistryPhase{mcpv1alpha1.MCPRegistryPhaseReady, mcpv1alpha1.MCPRegistryPhasePending}, MediumTimeout) - // Verify they operate independently - Expect(registry1.Spec.Sources[0].SyncPolicy.Interval).To(Equal("1h")) - Expect(registry2.Spec.Sources[0].SyncPolicy.Interval).To(Equal("30m")) - Expect(registry1.Spec.Sources[0].Format).To(Equal(mcpv1alpha1.RegistryFormatToolHive)) - Expect(registry2.Spec.Sources[0].Format).To(Equal(mcpv1alpha1.RegistryFormatToolHive)) + // Verify they operate independently by checking their configYAML + Expect(registry1.Spec.ConfigYAML).To(ContainSubstring("interval: 1h")) + Expect(registry2.Spec.ConfigYAML).To(ContainSubstring("interval: 30m")) + Expect(registry1.Spec.ConfigYAML).To(ContainSubstring("format: toolhive")) + Expect(registry2.Spec.ConfigYAML).To(ContainSubstring("format: toolhive")) }) It("should allow multiple registries with same ConfigMap source", func() { @@ -237,32 +237,9 @@ var _ = Describe("MCPRegistry Lifecycle Management", Label("k8s", "registry"), f Create(registryHelper) // Try to create second registry with same name - should fail - duplicateRegistry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "conflict-registry", - Namespace: testNamespace, - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap.Name, - }, - Key: "registry.json", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - }, - } + duplicateBuilder := registryHelper.NewRegistryBuilder("conflict-registry"). + WithConfigMapSource(configMap.Name, "registry.json") + duplicateRegistry := duplicateBuilder.Build() err := k8sClient.Create(ctx, duplicateRegistry) Expect(err).To(HaveOccurred()) diff --git a/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go b/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go index e485f40512..b06fba8b1b 100644 --- a/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go +++ b/cmd/thv-operator/test-integration/mcp-registry/registryserver_config_test.go @@ -13,6 +13,7 @@ import ( . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -104,11 +105,6 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis return k8sHelper.DeploymentExists(apiResourceName) }).Should(BeTrue(), "Registry API Deployment should exist") - // By("waiting for finalizer to be added") - // timingHelper.WaitForControllerReconciliation(func() interface{} { - // return containsFinalizer(registry.Finalizers, registryFinalizerName) - // }).Should(BeTrue(), "Registry should have finalizer") - service, err := k8sHelper.GetService(apiResourceName) Expect(err).NotTo(HaveOccurred()) Expect(service.Name).To(Equal(apiResourceName)) @@ -119,7 +115,6 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis // Verify the Deployment has correct configuration By("verifying the deployment is created") deployment := testHelpers.getDeploymentForRegistry(registry.Name) - Expect(err).NotTo(HaveOccurred()) Expect(deployment.Name).To(Equal(apiResourceName)) Expect(deployment.Namespace).To(Equal(testNamespace)) Expect(deployment.Spec.Template.Spec.Containers).To(HaveLen(1)) @@ -155,17 +150,18 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis // Verify basic properties testHelpers.verifyConfigMapBasics(serverConfigMap) - // Verify source-specific content + // Verify source-specific content: In the new model, the ConfigMap contains + // the verbatim configYAML, so we verify expected content strings are present configYAML := serverConfigMap.Data["config.yaml"] testHelpers.verifyConfigMapContent(configYAML, registry.Name, expectedConfigContent) // Verify the appropriate source type field is present (file, git, or api) - // This is determined by which source is configured in the registry - if registry.Spec.Sources[0].ConfigMapRef != nil { + // based on the configYAML content + if strings.Contains(registry.Spec.ConfigYAML, "file:") { Expect(configYAML).To(ContainSubstring("file:"), "ConfigMap source should have file field") - } else if registry.Spec.Sources[0].Git != nil { + } else if strings.Contains(registry.Spec.ConfigYAML, "git:") { Expect(configYAML).To(ContainSubstring("git:"), "Git source should have git field") - } else if registry.Spec.Sources[0].API != nil { + } else if strings.Contains(registry.Spec.ConfigYAML, "api:") { Expect(configYAML).To(ContainSubstring("api:"), "API source should have api field") } @@ -178,9 +174,6 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis By("checking source-specific volumes") verifySourceVolume(deployment, registry) - By("checking storage emptyDir volume and mount") - testHelpers.verifyStorageVolume(deployment) - By("verifying container arguments use the server config") testHelpers.verifyContainerArguments(deployment) }, @@ -197,7 +190,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis Create(registryHelper) }, map[string]string{ - "path": filepath.Join(config.RegistryJSONFilePath, "default", config.RegistryJSONFileName), + "path": "/config/registry/default/registry.json", "interval": "1h", }, func(deployment *appsv1.Deployment, registry *mcpv1alpha1.MCPRegistry) { @@ -315,60 +308,81 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis } Expect(k8sClient.Create(ctx, configMap3)).Should(Succeed()) - By("creating MCPRegistry with multiple ConfigMap sources") - registry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "multi-cm-volumes-test", - Namespace: testNamespace, + By("creating MCPRegistry with multiple ConfigMap sources via configYAML") + configYAML := buildConfigYAMLForMultipleSources([]map[string]string{ + { + "name": "alpha", + "sourceType": "file", + "filePath": "/config/registry/alpha/registry.json", + "interval": "10m", }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "alpha", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap1.Name, - }, - Key: "servers.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "10m", - }, - }, - { - Name: "beta", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap2.Name, - }, - Key: "data.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "15m", - }, + { + "name": "beta", + "sourceType": "file", + "filePath": "/config/registry/beta/registry.json", + "interval": "15m", + }, + { + "name": "gamma", + "sourceType": "file", + "filePath": "/config/registry/gamma/registry.json", + "interval": "20m", + }, + }) + + // Build volumes for all three ConfigMap sources + volumes := []apiextensionsv1.JSON{ + {Raw: mustMarshalJSON(corev1.Volume{ + Name: "registry-data-source-alpha", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMap1.Name}, + Items: []corev1.KeyToPath{{Key: "servers.json", Path: "registry.json"}}, }, - { - Name: "gamma", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap3.Name, - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "20m", - }, + }, + })}, + {Raw: mustMarshalJSON(corev1.Volume{ + Name: "registry-data-source-beta", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMap2.Name}, + Items: []corev1.KeyToPath{{Key: "data.json", Path: "registry.json"}}, }, }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"alpha", "beta", "gamma"}, + })}, + {Raw: mustMarshalJSON(corev1.Volume{ + Name: "registry-data-source-gamma", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMap3.Name}, + Items: []corev1.KeyToPath{{Key: "registry.json", Path: "registry.json"}}, }, }, + })}, + } + + // Build volume mounts for all three sources + volumeMounts := []apiextensionsv1.JSON{ + {Raw: mustMarshalJSON(corev1.VolumeMount{ + Name: "registry-data-source-alpha", MountPath: "/config/registry/alpha", ReadOnly: true, + })}, + {Raw: mustMarshalJSON(corev1.VolumeMount{ + Name: "registry-data-source-beta", MountPath: "/config/registry/beta", ReadOnly: true, + })}, + {Raw: mustMarshalJSON(corev1.VolumeMount{ + Name: "registry-data-source-gamma", MountPath: "/config/registry/gamma", ReadOnly: true, + })}, + } + + registry := &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: "multi-cm-volumes-test", + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPRegistrySpec{ + ConfigYAML: configYAML, + Volumes: volumes, + VolumeMounts: volumeMounts, }, } Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) @@ -455,23 +469,23 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis }, serverConfig) }, QuickTimeout, DefaultPollingInterval).Should(Succeed()) - configYAML := serverConfig.Data["config.yaml"] - Expect(configYAML).NotTo(BeEmpty()) + serverConfigYAML := serverConfig.Data["config.yaml"] + Expect(serverConfigYAML).NotTo(BeEmpty()) // Verify all three sources are in the config with correct file paths - Expect(configYAML).To(ContainSubstring("name: alpha")) - Expect(configYAML).To(ContainSubstring("name: beta")) - Expect(configYAML).To(ContainSubstring("name: gamma")) + Expect(serverConfigYAML).To(ContainSubstring("name: alpha")) + Expect(serverConfigYAML).To(ContainSubstring("name: beta")) + Expect(serverConfigYAML).To(ContainSubstring("name: gamma")) // Verify file paths are correct - Expect(configYAML).To(ContainSubstring("path: /config/registry/alpha/registry.json")) - Expect(configYAML).To(ContainSubstring("path: /config/registry/beta/registry.json")) - Expect(configYAML).To(ContainSubstring("path: /config/registry/gamma/registry.json")) + Expect(serverConfigYAML).To(ContainSubstring("path: /config/registry/alpha/registry.json")) + Expect(serverConfigYAML).To(ContainSubstring("path: /config/registry/beta/registry.json")) + Expect(serverConfigYAML).To(ContainSubstring("path: /config/registry/gamma/registry.json")) // Verify sync intervals - Expect(configYAML).To(ContainSubstring("interval: 10m")) - Expect(configYAML).To(ContainSubstring("interval: 15m")) - Expect(configYAML).To(ContainSubstring("interval: 20m")) + Expect(serverConfigYAML).To(ContainSubstring("interval: 10m")) + Expect(serverConfigYAML).To(ContainSubstring("interval: 15m")) + Expect(serverConfigYAML).To(ContainSubstring("interval: 20m")) By("cleaning up") Expect(k8sClient.Delete(ctx, registry)).Should(Succeed()) @@ -499,16 +513,49 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis } Expect(k8sClient.Create(ctx, gitSecret)).Should(Succeed()) - By("creating MCPRegistry with Git source and authentication") - registry := registryHelper.NewRegistryBuilder("git-auth-test"). - WithGitSource( - "https://github.com/example/private-repo.git", - "main", - "registry.json", - ). - WithGitAuth("git", "git-auth-secret", "token"). - WithSyncPolicy("1h"). - Create(registryHelper) + By("creating MCPRegistry with Git source and authentication via configYAML") + // Build configYAML with git auth + gitConfigYAML := buildConfigYAMLForMultipleSources([]map[string]string{ + { + "name": "default", + "sourceType": "git", + "repository": "https://github.com/example/private-repo.git", + "branch": "main", + "path": "registry.json", + "authUsername": "git", + "authPasswordFile": "/secrets/git-auth-secret/token", + "interval": "1h", + }, + }) + + // Build secret volume and mount for git auth + secretVol := corev1.Volume{ + Name: "git-auth-git-auth-secret", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "git-auth-secret", + Items: []corev1.KeyToPath{{Key: "token", Path: "token"}}, + }, + }, + } + secretMount := corev1.VolumeMount{ + Name: "git-auth-git-auth-secret", + MountPath: "/secrets/git-auth-secret", + ReadOnly: true, + } + + registry := &mcpv1alpha1.MCPRegistry{ + ObjectMeta: metav1.ObjectMeta{ + Name: "git-auth-test", + Namespace: testNamespace, + }, + Spec: mcpv1alpha1.MCPRegistrySpec{ + ConfigYAML: gitConfigYAML, + Volumes: []apiextensionsv1.JSON{{Raw: mustMarshalJSON(secretVol)}}, + VolumeMounts: []apiextensionsv1.JSON{{Raw: mustMarshalJSON(secretMount)}}, + }, + } + Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) By("waiting for deployment to be created") deployment := &appsv1.Deployment{} @@ -532,10 +579,10 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis }, serverConfig) }, QuickTimeout, DefaultPollingInterval).Should(Succeed()) - configYAML := serverConfig.Data["config.yaml"] - Expect(configYAML).To(ContainSubstring("auth:")) - Expect(configYAML).To(ContainSubstring("username: git")) - Expect(configYAML).To(ContainSubstring("passwordFile: /secrets/git-auth-secret/token")) + serverConfigYAML := serverConfig.Data["config.yaml"] + Expect(serverConfigYAML).To(ContainSubstring("auth:")) + Expect(serverConfigYAML).To(ContainSubstring("username: git")) + Expect(serverConfigYAML).To(ContainSubstring("passwordFile: /secrets/git-auth-secret/token")) By("cleaning up") Expect(k8sClient.Delete(ctx, registry)).Should(Succeed()) @@ -571,62 +618,69 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis Expect(k8sClient.Create(ctx, gitSecret2)).Should(Succeed()) By("creating MCPRegistry with multiple Git sources with different auth") + multiGitConfigYAML := buildConfigYAMLForMultipleSources([]map[string]string{ + { + "name": "private-repo-1", + "sourceType": "git", + "repository": "https://github.com/org/repo1.git", + "branch": "main", + "path": "registry.json", + "authUsername": "user1", + "authPasswordFile": "/secrets/git-auth-1/password", + "interval": "30m", + }, + { + "name": "private-repo-2", + "sourceType": "git", + "repository": "https://github.com/org/repo2.git", + "branch": "develop", + "path": "servers.json", + "authUsername": "user2", + "authPasswordFile": "/secrets/git-auth-2/token", + "interval": "1h", + }, + }) + + // Build volumes and mounts for both auth secrets + volumes := []apiextensionsv1.JSON{ + {Raw: mustMarshalJSON(corev1.Volume{ + Name: "git-auth-git-auth-1", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "git-auth-1", + Items: []corev1.KeyToPath{{Key: "password", Path: "password"}}, + }, + }, + })}, + {Raw: mustMarshalJSON(corev1.Volume{ + Name: "git-auth-git-auth-2", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: "git-auth-2", + Items: []corev1.KeyToPath{{Key: "token", Path: "token"}}, + }, + }, + })}, + } + + volumeMounts := []apiextensionsv1.JSON{ + {Raw: mustMarshalJSON(corev1.VolumeMount{ + Name: "git-auth-git-auth-1", MountPath: "/secrets/git-auth-1", ReadOnly: true, + })}, + {Raw: mustMarshalJSON(corev1.VolumeMount{ + Name: "git-auth-git-auth-2", MountPath: "/secrets/git-auth-2", ReadOnly: true, + })}, + } + registry := &mcpv1alpha1.MCPRegistry{ ObjectMeta: metav1.ObjectMeta{ Name: "multi-git-auth-test", Namespace: testNamespace, }, Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "private-repo-1", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/org/repo1.git", - Branch: "main", - Path: "registry.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "user1", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-auth-1", - }, - Key: "password", - }, - }, - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "30m", - }, - }, - { - Name: "private-repo-2", - Format: mcpv1alpha1.RegistryFormatToolHive, - Git: &mcpv1alpha1.GitSource{ - Repository: "https://github.com/org/repo2.git", - Branch: "develop", - Path: "servers.json", - Auth: &mcpv1alpha1.GitAuthConfig{ - Username: "user2", - PasswordSecretRef: corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "git-auth-2", - }, - Key: "token", - }, - }, - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"private-repo-1", "private-repo-2"}, - }, - }, + ConfigYAML: multiGitConfigYAML, + Volumes: volumes, + VolumeMounts: volumeMounts, }, } Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) @@ -654,17 +708,17 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis }, serverConfig) }, QuickTimeout, DefaultPollingInterval).Should(Succeed()) - configYAML := serverConfig.Data["config.yaml"] + serverConfigYAML := serverConfig.Data["config.yaml"] // Verify first registry auth - Expect(configYAML).To(ContainSubstring("name: private-repo-1")) - Expect(configYAML).To(ContainSubstring("username: user1")) - Expect(configYAML).To(ContainSubstring("passwordFile: /secrets/git-auth-1/password")) + Expect(serverConfigYAML).To(ContainSubstring("name: private-repo-1")) + Expect(serverConfigYAML).To(ContainSubstring("username: user1")) + Expect(serverConfigYAML).To(ContainSubstring("passwordFile: /secrets/git-auth-1/password")) // Verify second registry auth - Expect(configYAML).To(ContainSubstring("name: private-repo-2")) - Expect(configYAML).To(ContainSubstring("username: user2")) - Expect(configYAML).To(ContainSubstring("passwordFile: /secrets/git-auth-2/token")) + Expect(serverConfigYAML).To(ContainSubstring("name: private-repo-2")) + Expect(serverConfigYAML).To(ContainSubstring("username: user2")) + Expect(serverConfigYAML).To(ContainSubstring("passwordFile: /secrets/git-auth-2/token")) By("cleaning up") Expect(k8sClient.Delete(ctx, registry)).Should(Succeed()) @@ -683,45 +737,21 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis configMap := configMapHelper.CreateSampleToolHiveRegistry("podspec-sa-test") By("creating MCPRegistry with custom service account in PodTemplateSpec") - registry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "podspec-sa-test", - Namespace: testNamespace, - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap.Name, - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - PodTemplateSpec: &runtime.RawExtension{ - Raw: []byte(`{"spec":{"serviceAccountName":"custom-integration-test-sa"}}`), - }, - }, + registryObj := registryHelper.NewRegistryBuilder("podspec-sa-test"). + WithConfigMapSource(configMap.Name, "registry.json"). + WithSyncPolicy("1h"). + Build() + registryObj.Spec.PodTemplateSpec = &runtime.RawExtension{ + Raw: []byte(`{"spec":{"serviceAccountName":"custom-integration-test-sa"}}`), } - Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) + + Expect(k8sClient.Create(ctx, registryObj)).Should(Succeed()) By("waiting for deployment to be created") deployment := &appsv1.Deployment{} Eventually(func() error { return k8sClient.Get(ctx, client.ObjectKey{ - Name: fmt.Sprintf("%s-api", registry.Name), + Name: fmt.Sprintf("%s-api", registryObj.Name), Namespace: testNamespace, }, deployment) }, MediumTimeout, DefaultPollingInterval).Should(Succeed()) @@ -734,7 +764,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis testHelpers.verifyPodTemplateValidCondition("podspec-sa-test", true) By("cleaning up") - Expect(k8sClient.Delete(ctx, registry)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, registryObj)).Should(Succeed()) Expect(k8sClient.Delete(ctx, configMap)).Should(Succeed()) timingHelper.WaitForControllerReconciliation(func() interface{} { _, err := registryHelper.GetRegistry("podspec-sa-test") @@ -747,45 +777,21 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis configMap := configMapHelper.CreateSampleToolHiveRegistry("podspec-tolerations-test") By("creating MCPRegistry with custom tolerations in PodTemplateSpec") - registry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "podspec-tolerations-test", - Namespace: testNamespace, - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap.Name, - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - PodTemplateSpec: &runtime.RawExtension{ - Raw: []byte(`{"spec":{"tolerations":[{"key":"special-node","operator":"Equal","value":"true","effect":"NoSchedule"}]}}`), - }, - }, + registryObj := registryHelper.NewRegistryBuilder("podspec-tolerations-test"). + WithConfigMapSource(configMap.Name, "registry.json"). + WithSyncPolicy("1h"). + Build() + registryObj.Spec.PodTemplateSpec = &runtime.RawExtension{ + Raw: []byte(`{"spec":{"tolerations":[{"key":"special-node","operator":"Equal","value":"true","effect":"NoSchedule"}]}}`), } - Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) + + Expect(k8sClient.Create(ctx, registryObj)).Should(Succeed()) By("waiting for deployment to be created") deployment := &appsv1.Deployment{} Eventually(func() error { return k8sClient.Get(ctx, client.ObjectKey{ - Name: fmt.Sprintf("%s-api", registry.Name), + Name: fmt.Sprintf("%s-api", registryObj.Name), Namespace: testNamespace, }, deployment) }, MediumTimeout, DefaultPollingInterval).Should(Succeed()) @@ -805,7 +811,7 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis testHelpers.verifyPodTemplateValidCondition("podspec-tolerations-test", true) By("cleaning up") - Expect(k8sClient.Delete(ctx, registry)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, registryObj)).Should(Succeed()) Expect(k8sClient.Delete(ctx, configMap)).Should(Succeed()) timingHelper.WaitForControllerReconciliation(func() interface{} { _, err := registryHelper.GetRegistry("podspec-tolerations-test") @@ -818,39 +824,15 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis configMap := configMapHelper.CreateSampleToolHiveRegistry("podspec-invalid-test") By("creating MCPRegistry with invalid JSON in PodTemplateSpec") - registry := &mcpv1alpha1.MCPRegistry{ - ObjectMeta: metav1.ObjectMeta{ - Name: "podspec-invalid-test", - Namespace: testNamespace, - }, - Spec: mcpv1alpha1.MCPRegistrySpec{ - Sources: []mcpv1alpha1.MCPRegistrySourceConfig{ - { - Name: "default", - Format: mcpv1alpha1.RegistryFormatToolHive, - ConfigMapRef: &corev1.ConfigMapKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: configMap.Name, - }, - Key: "registry.json", - }, - SyncPolicy: &mcpv1alpha1.SyncPolicy{ - Interval: "1h", - }, - }, - }, - Registries: []mcpv1alpha1.MCPRegistryViewConfig{ - { - Name: "default", - Sources: []string{"default"}, - }, - }, - PodTemplateSpec: &runtime.RawExtension{ - Raw: []byte(`{"spec": "invalid"}`), - }, - }, + registryObj := registryHelper.NewRegistryBuilder("podspec-invalid-test"). + WithConfigMapSource(configMap.Name, "registry.json"). + WithSyncPolicy("1h"). + Build() + registryObj.Spec.PodTemplateSpec = &runtime.RawExtension{ + Raw: []byte(`{"spec": "invalid"}`), } - Expect(k8sClient.Create(ctx, registry)).Should(Succeed()) + + Expect(k8sClient.Create(ctx, registryObj)).Should(Succeed()) By("waiting for registry status to be updated with failure") testHelpers.verifyRegistryFailedWithInvalidPodTemplate("podspec-invalid-test") @@ -862,14 +844,14 @@ var _ = Describe("MCPRegistry Server Config (Consolidated)", Label("k8s", "regis deployment := &appsv1.Deployment{} Consistently(func() bool { err := k8sClient.Get(ctx, client.ObjectKey{ - Name: fmt.Sprintf("%s-api", registry.Name), + Name: fmt.Sprintf("%s-api", registryObj.Name), Namespace: testNamespace, }, deployment) return errors.IsNotFound(err) }, QuickTimeout, DefaultPollingInterval).Should(BeTrue(), "Deployment should NOT be created when PodTemplateSpec is invalid") By("cleaning up") - Expect(k8sClient.Delete(ctx, registry)).Should(Succeed()) + Expect(k8sClient.Delete(ctx, registryObj)).Should(Succeed()) Expect(k8sClient.Delete(ctx, configMap)).Should(Succeed()) timingHelper.WaitForControllerReconciliation(func() interface{} { _, err := registryHelper.GetRegistry("podspec-invalid-test") @@ -904,30 +886,6 @@ func (*serverConfigTestHelpers) verifyServerConfigVolume(deployment *appsv1.Depl Expect(mountFound).To(BeTrue(), "Deployment should have a volume mount for the registry config ConfigMap") } -func (*serverConfigTestHelpers) verifyStorageVolume(deployment *appsv1.Deployment) { - // Check volume - storageVolumeFound := false - for _, volume := range deployment.Spec.Template.Spec.Volumes { - if volume.Name == "storage-data" && volume.EmptyDir != nil { - storageVolumeFound = true - break - } - } - Expect(storageVolumeFound).To(BeTrue(), "Deployment should have an emptyDir volume for storage") - - // Check mount - storageMountFound := false - for _, mount := range deployment.Spec.Template.Spec.Containers[0].VolumeMounts { - if mount.Name == "storage-data" { - Expect(mount.MountPath).To(Equal("/data")) - Expect(mount.ReadOnly).To(BeFalse(), "Storage mount should be writable") - storageMountFound = true - break - } - } - Expect(storageMountFound).To(BeTrue(), "Deployment should have a volume mount for the storage emptyDir") -} - func (*serverConfigTestHelpers) verifyContainerArguments(deployment *appsv1.Deployment) { container := deployment.Spec.Template.Spec.Containers[0] Expect(container.Args).To(ContainElement("serve")) @@ -972,43 +930,50 @@ func (*serverConfigTestHelpers) verifyNoSourceDataVolume(deployment *appsv1.Depl } // verifySourceDataVolume verifies the source data ConfigMap volume for ConfigMap sources +// by checking the user-provided Volumes/VolumeMounts on the registry spec. func (*serverConfigTestHelpers) verifySourceDataVolume(deployment *appsv1.Deployment, registry *mcpv1alpha1.MCPRegistry) { - // With multiple source support, we need to check each ConfigMap source - for _, sourceConfig := range registry.Spec.Sources { - if sourceConfig.ConfigMapRef != nil { - expectedSourceConfigMapName := sourceConfig.ConfigMapRef.Name - expectedVolumeName := fmt.Sprintf("registry-data-source-%s", sourceConfig.Name) - expectedMountPath := filepath.Join(config.RegistryJSONFilePath, sourceConfig.Name) - - // Check volume - sourceDataVolumeFound := false - for _, volume := range deployment.Spec.Template.Spec.Volumes { - if volume.Name == expectedVolumeName && volume.ConfigMap != nil { - Expect(volume.ConfigMap.LocalObjectReference.Name).To(Equal(expectedSourceConfigMapName)) - // Check that it mounts the correct key as registry.json - Expect(volume.ConfigMap.Items).To(HaveLen(1)) - Expect(volume.ConfigMap.Items[0].Key).To(Equal(sourceConfig.ConfigMapRef.Key)) - Expect(volume.ConfigMap.Items[0].Path).To(Equal("registry.json")) - sourceDataVolumeFound = true - break - } + // Parse volumes from the registry spec to understand expected volume configuration + userVolumes, err := registry.Spec.ParseVolumes() + Expect(err).NotTo(HaveOccurred()) + + for _, userVol := range userVolumes { + if !strings.HasPrefix(userVol.Name, "registry-data-source-") { + continue + } + + // Check that the volume exists in the deployment + sourceDataVolumeFound := false + for _, volume := range deployment.Spec.Template.Spec.Volumes { + if volume.Name == userVol.Name && volume.ConfigMap != nil { + Expect(volume.ConfigMap.LocalObjectReference.Name).To(Equal(userVol.ConfigMap.Name)) + sourceDataVolumeFound = true + break } - Expect(sourceDataVolumeFound).To(BeTrue(), - fmt.Sprintf("Deployment should have a volume for ConfigMap source %s", sourceConfig.Name)) - - // Check mount - sourceDataMountFound := false - for _, mount := range deployment.Spec.Template.Spec.Containers[0].VolumeMounts { - if mount.Name == expectedVolumeName { - Expect(mount.MountPath).To(Equal(expectedMountPath)) - Expect(mount.ReadOnly).To(BeTrue()) - sourceDataMountFound = true - break - } + } + Expect(sourceDataVolumeFound).To(BeTrue(), + fmt.Sprintf("Deployment should have volume %s", userVol.Name)) + } + + // Also check that user-provided mounts exist + userMounts, err := registry.Spec.ParseVolumeMounts() + Expect(err).NotTo(HaveOccurred()) + + for _, userMount := range userMounts { + if !strings.HasPrefix(userMount.Name, "registry-data-source-") { + continue + } + + sourceDataMountFound := false + for _, mount := range deployment.Spec.Template.Spec.Containers[0].VolumeMounts { + if mount.Name == userMount.Name { + Expect(mount.MountPath).To(Equal(userMount.MountPath)) + Expect(mount.ReadOnly).To(BeTrue()) + sourceDataMountFound = true + break } - Expect(sourceDataMountFound).To(BeTrue(), - fmt.Sprintf("Deployment should have a volume mount for ConfigMap source %s", sourceConfig.Name)) } + Expect(sourceDataMountFound).To(BeTrue(), + fmt.Sprintf("Deployment should have volume mount %s", userMount.Name)) } } @@ -1041,10 +1006,8 @@ func (*serverConfigTestHelpers) verifyConfigMapBasics(configMap *corev1.ConfigMa // verifyConfigMapContent verifies source-specific content in the config.yaml func (*serverConfigTestHelpers) verifyConfigMapContent(configYAML string, _ string, expectedContent map[string]string) { - Expect(configYAML).To(ContainSubstring("sources:")) - Expect(configYAML).To(ContainSubstring("registries:")) - Expect(configYAML).To(ContainSubstring("format: toolhive")) - + // In the new model, the server config ConfigMap contains the verbatim configYAML. + // Verify expected key-value pairs are present in the content. for key, value := range expectedContent { Expect(configYAML).To(ContainSubstring(fmt.Sprintf("%s: %s", key, value))) } diff --git a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml index 97486b16af..1a58eb4d36 100644 --- a/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml +++ b/deploy/charts/operator-crds/files/crds/toolhive.stacklok.dev_mcpregistries.yaml @@ -60,432 +60,23 @@ spec: spec: description: MCPRegistrySpec defines the desired state of MCPRegistry properties: - authConfig: - description: |- - AuthConfig defines the authentication configuration for the registry API server. - If not specified, defaults to anonymous authentication. - Deprecated: Put auth config in configYAML instead. - properties: - authz: - description: Authz defines authorization configuration for role-based - access control. - properties: - roles: - description: |- - Roles defines the role-based authorization rules. - Each role is a list of claim matchers (JSON objects with string or []string values). - properties: - manageEntries: - description: ManageEntries grants permission to create, - update, and delete registry entries. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - manageRegistries: - description: ManageRegistries grants permission to create, - update, and delete registries. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - manageSources: - description: ManageSources grants permission to create, - update, and delete sources. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - superAdmin: - description: SuperAdmin grants full administrative access - to the registry. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - type: object - type: object - mode: - default: anonymous - description: |- - Mode specifies the authentication mode (anonymous or oauth) - Defaults to "anonymous" if not specified. - Use "oauth" to enable OAuth/OIDC authentication. - enum: - - anonymous - - oauth - type: string - oauth: - description: |- - OAuth defines OAuth/OIDC specific authentication settings - Only used when Mode is "oauth" - properties: - providers: - description: |- - Providers defines the OAuth/OIDC providers for authentication - Multiple providers can be configured (e.g., Kubernetes + external IDP) - items: - description: MCPRegistryOAuthProviderConfig defines configuration - for an OAuth/OIDC provider - properties: - allowPrivateIP: - default: false - description: |- - AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Required when the OAuth provider (e.g., Kubernetes API server) is running on a private network - Example: Set to true when using https://kubernetes.default.svc as the issuer URL - type: boolean - audience: - description: |- - Audience is the expected audience claim in the token (REQUIRED) - Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience - For Kubernetes, this is typically the API server URL - minLength: 1 - type: string - authTokenFile: - description: |- - AuthTokenFile is the path to a file containing a bearer token for authenticating to OIDC/JWKS endpoints. - Useful when the OIDC discovery or JWKS endpoint requires authentication. - Example: /var/run/secrets/kubernetes.io/serviceaccount/token - type: string - authTokenRef: - description: |- - AuthTokenRef is a reference to a Secret containing a bearer token for authenticating - to OIDC/JWKS endpoints. Useful when the OIDC discovery or JWKS endpoint requires authentication. - Example: ServiceAccount token for Kubernetes API server - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - caCertPath: - description: |- - CaCertPath is the path to the CA certificate bundle for verifying the provider's TLS certificate. - Required for Kubernetes in-cluster authentication or self-signed certificates - type: string - caCertRef: - description: |- - CACertRef is a reference to a ConfigMap containing the CA certificate bundle - for verifying the provider's TLS certificate. - Required for Kubernetes in-cluster authentication or self-signed certificates - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - clientId: - description: ClientID is the OAuth client ID for token - introspection (optional) - type: string - clientSecretRef: - description: |- - ClientSecretRef is a reference to a Secret containing the client secret - The secret should have a key "clientSecret" containing the secret value - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - introspectionUrl: - description: |- - IntrospectionURL is the OAuth 2.0 Token Introspection endpoint (RFC 7662) - Used for validating opaque (non-JWT) tokens - If not specified, only JWT tokens can be validated via JWKS - pattern: ^https?://.* - type: string - issuerUrl: - description: |- - IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com) - The JWKS URL will be discovered automatically from .well-known/openid-configuration - unless JwksUrl is explicitly specified - minLength: 1 - pattern: ^https?://.* - type: string - jwksUrl: - description: |- - JwksUrl is the URL to fetch the JSON Web Key Set (JWKS) from - If specified, OIDC discovery is skipped and this URL is used directly - Example: https://kubernetes.default.svc/openid/v1/jwks - pattern: ^https?://.* - type: string - name: - description: Name is a unique identifier for this provider - (e.g., "kubernetes", "keycloak") - minLength: 1 - type: string - required: - - audience - - issuerUrl - - name - type: object - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - realm: - description: |- - Realm is the protection space identifier for WWW-Authenticate header (RFC 7235) - Defaults to "mcp-registry" if not specified - type: string - resourceUrl: - description: |- - ResourceURL is the URL identifying this protected resource (RFC 9728) - Used in the /.well-known/oauth-protected-resource endpoint - type: string - scopesSupported: - description: |- - ScopesSupported defines the OAuth scopes supported by this resource (RFC 9728) - Defaults to ["mcp-registry:read", "mcp-registry:write"] if not specified - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - publicPaths: - description: |- - PublicPaths defines additional paths that bypass authentication. - These extend the default public paths (health, docs, swagger, well-known). - Each path must start with "/". Do not add API data paths here. - Example: ["/custom/public", "/metrics"] - items: - minLength: 1 - pattern: ^/ - type: string - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-validations: - - message: authz configuration has no effect when auth mode is anonymous - rule: self.mode != 'anonymous' || !has(self.authz) configYAML: description: |- ConfigYAML is the complete registry server config.yaml content. The operator creates a ConfigMap from this string and mounts it at /config/config.yaml in the registry-api container. - The operator does NOT parse, validate, or transform this content. + The operator does NOT parse, validate, or transform this content — + configuration validation is the registry server's responsibility. - Mutually exclusive with the legacy typed fields (Sources, Registries, - DatabaseConfig, AuthConfig, TelemetryConfig). When set, the operator - uses the decoupled code path — volumes and mounts must be provided - via the Volumes and VolumeMounts fields below. + Security note: this content is stored in a ConfigMap, not a Secret. + Do not inline credentials (passwords, tokens, client secrets) in this + field. Instead, reference credentials via file paths and mount the + actual secrets using the Volumes and VolumeMounts fields. For database + passwords, use PGPassSecretRef. + minLength: 1 type: string - databaseConfig: - description: |- - DatabaseConfig defines the PostgreSQL database configuration for the registry API server. - If not specified, defaults will be used: - - Host: "postgres" - - Port: 5432 - - User: "db_app" - - MigrationUser: "db_migrator" - - Database: "registry" - - SSLMode: "prefer" - - MaxOpenConns: 10 - - MaxIdleConns: 2 - - ConnMaxLifetime: "30m" - - Deprecated: Put database config in configYAML and use pgpassSecretRef. - properties: - connMaxLifetime: - default: 30m - description: |- - ConnMaxLifetime is the maximum amount of time a connection may be reused (Go duration format) - Examples: "30m", "1h", "24h" - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - type: string - database: - default: registry - description: Database is the database name - type: string - dbAppUserPasswordSecretRef: - description: |- - DBAppUserPasswordSecretRef references a Kubernetes Secret containing the password for the application database user. - The operator will use this password along with DBMigrationUserPasswordSecretRef to generate a pgpass file - that is mounted to the registry API container. - properties: - key: - description: The key of the secret to select from. Must be - a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key must be - defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - dbMigrationUserPasswordSecretRef: - description: |- - DBMigrationUserPasswordSecretRef references a Kubernetes Secret containing the password for the migration database user. - The operator will use this password along with DBAppUserPasswordSecretRef to generate a pgpass file - that is mounted to the registry API container. - properties: - key: - description: The key of the secret to select from. Must be - a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key must be - defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - dynamicAuth: - description: |- - DynamicAuth defines dynamic database authentication configuration. - When set, the registry server authenticates to the database using - short-lived credentials instead of static passwords. - properties: - awsRdsIam: - description: AWSRDSIAM enables AWS RDS IAM authentication - for database connections. - properties: - region: - description: |- - Region is the AWS region for RDS IAM authentication. - Use "detect" to automatically detect the region from instance metadata. - minLength: 1 - type: string - type: object - type: object - host: - default: postgres - description: Host is the database server hostname - type: string - maxIdleConns: - default: 2 - description: MaxIdleConns is the maximum number of idle connections - in the pool - format: int32 - minimum: 0 - type: integer - maxMetaSize: - description: |- - MaxMetaSize is the maximum allowed size in bytes for publisher-provided - metadata extensions (_meta). Must be greater than zero. - Defaults to 262144 (256KB) if not specified. - format: int32 - minimum: 1 - type: integer - maxOpenConns: - default: 10 - description: MaxOpenConns is the maximum number of open connections - to the database - format: int32 - minimum: 1 - type: integer - migrationUser: - default: db_migrator - description: |- - MigrationUser is the migration user (elevated privileges: CREATE, ALTER, DROP) - Used for running database schema migrations - Credentials should be provided via pgpass file or environment variables - type: string - port: - default: 5432 - description: Port is the database server port - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sslMode: - default: prefer - description: |- - SSLMode is the SSL mode for the connection - Valid values: disable, allow, prefer, require, verify-ca, verify-full - enum: - - disable - - allow - - prefer - - require - - verify-ca - - verify-full - type: string - user: - default: db_app - description: |- - User is the application user (limited privileges: SELECT, INSERT, UPDATE, DELETE) - Credentials should be provided via pgpass file or environment variables - type: string - required: - - dbAppUserPasswordSecretRef - - dbMigrationUserPasswordSecretRef - type: object displayName: - description: |- - DisplayName is a human-readable name for the registry. - Works with both the new configYAML path and the legacy typed path. + description: DisplayName is a human-readable name for the registry. type: string enforceServers: default: false @@ -498,16 +89,15 @@ spec: type: boolean pgpassSecretRef: description: "PGPassSecretRef references a Secret containing a pre-created - pgpass file.\nOnly used when configYAML is set. Mutually exclusive - with DatabaseConfig.\n\nWhy this is a dedicated field instead of - a regular volume/volumeMount:\nPostgreSQL's libpq rejects pgpass - files that aren't mode 0600. Kubernetes\nsecret volumes mount files - as root-owned, and the registry-api container\nruns as non-root - (UID 65532). A root-owned 0600 file is unreadable by\nUID 65532, - and using fsGroup changes permissions to 0640 which libpq also\nrejects. - The only solution is an init container that copies the file to an\nemptyDir + pgpass file.\n\nWhy this is a dedicated field instead of a regular + volume/volumeMount:\nPostgreSQL's libpq rejects pgpass files that + aren't mode 0600. Kubernetes\nsecret volumes mount files as root-owned, + and the registry-api container\nruns as non-root (UID 65532). A + root-owned 0600 file is unreadable by\nUID 65532, and using fsGroup + changes permissions to 0640 which libpq also\nrejects. The only + solution is an init container that copies the file to an\nemptyDir as the app user and runs chmod 0600. This cannot be expressed\nthrough - volumes/volumeMounts alone — it requires an init container, two\nextra + volumes/volumeMounts alone -- it requires an init container, two\nextra volumes (secret + emptyDir), a subPath mount, and an environment\nvariable, all wired together correctly.\n\nWhen specified, the operator generates all of that plumbing invisibly.\nThe user creates the Secret with @@ -545,383 +135,13 @@ spec: Note that to modify the specific container the registry API server runs in, you must specify the `registry-api` container name in the PodTemplateSpec. This field accepts a PodTemplateSpec object as JSON/YAML. - Works with both the new configYAML path and the legacy typed path. type: object x-kubernetes-preserve-unknown-fields: true - registries: - description: |- - Registries defines lightweight registry views that aggregate one or more sources. - Each registry references sources by name and can optionally gate access via claims. - Deprecated: Use configYAML with volumes/volumeMounts instead. - items: - description: MCPRegistryViewConfig defines a lightweight registry - view that aggregates one or more sources. - properties: - claims: - description: |- - Claims are key-value pairs that gate access to this registry view. - Only requests with matching claims can access this registry. Values must be string or []string. - type: object - x-kubernetes-preserve-unknown-fields: true - name: - description: Name is a unique identifier for this registry view - minLength: 1 - type: string - sources: - description: |- - Sources is an ordered list of source names that feed this registry. - Each name must reference a source defined in spec.sources. - items: - type: string - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - name - - sources - type: object - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - sources: - description: |- - Sources defines the data source configurations for the registry. - Each source defines where registry data comes from (Git, API, ConfigMap, URL, Managed, or Kubernetes). - Deprecated: Use configYAML with volumes/volumeMounts instead. - items: - description: |- - MCPRegistrySourceConfig defines a data source configuration for the registry. - Exactly one source type must be specified (ConfigMapRef, Git, API, URL, Managed, or Kubernetes). - properties: - api: - description: |- - API defines the API source configuration - Mutually exclusive with ConfigMapRef, Git, URL, Managed, and Kubernetes - properties: - endpoint: - description: |- - Endpoint is the base API URL (without path) - The controller will append the appropriate paths: - Phase 1 (ToolHive API): - - /v0/servers - List all servers (single response, no pagination) - - /v0/servers/{name} - Get specific server (future) - - /v0/info - Get registry metadata (future) - Example: "http://my-registry-api.default.svc.cluster.local/api" - minLength: 1 - pattern: ^https?://.* - type: string - required: - - endpoint - type: object - claims: - description: |- - Claims are key-value pairs attached to this source for authorization purposes. - All entries from this source inherit these claims. Values must be string or []string. - type: object - x-kubernetes-preserve-unknown-fields: true - configMapRef: - description: |- - ConfigMapRef defines the ConfigMap source configuration - Mutually exclusive with Git, API, URL, Managed, and Kubernetes - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its key must - be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - filter: - description: |- - Filter defines include/exclude patterns for registry content. - Not applicable for Managed and Kubernetes sources (will be ignored). - properties: - names: - description: NameFilters defines name-based filtering - properties: - exclude: - description: Exclude is a list of glob patterns to exclude - items: - type: string - type: array - x-kubernetes-list-type: atomic - include: - description: Include is a list of glob patterns to include - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - tags: - description: Tags defines tag-based filtering - properties: - exclude: - description: Exclude is a list of tags to exclude - items: - type: string - type: array - x-kubernetes-list-type: atomic - include: - description: Include is a list of tags to include - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - type: object - format: - default: toolhive - description: Format is the data format (toolhive, upstream) - enum: - - toolhive - - upstream - type: string - git: - description: |- - Git defines the Git repository source configuration - Mutually exclusive with ConfigMapRef, API, URL, Managed, and Kubernetes - properties: - auth: - description: |- - Auth defines optional authentication for private Git repositories. - When specified, enables HTTP Basic authentication using the provided - username and password/token from a Kubernetes Secret. - properties: - passwordSecretRef: - description: |- - PasswordSecretRef references a Kubernetes Secret containing the password or token - for Git authentication. The secret value will be mounted as a file and its path - passed to the registry server via the git.auth.passwordFile configuration. - - Example secret: - apiVersion: v1 - kind: Secret - metadata: - name: git-credentials - stringData: - token: - - Then reference it as: - passwordSecretRef: - name: git-credentials - key: token - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - username: - description: |- - Username is the Git username for HTTP Basic authentication. - For GitHub/GitLab token-based auth, this is typically the literal string "git" - or the token itself depending on the provider. - minLength: 1 - type: string - required: - - passwordSecretRef - - username - type: object - branch: - description: Branch is the Git branch to use (mutually exclusive - with Tag and Commit) - minLength: 1 - type: string - commit: - description: Commit is the Git commit SHA to use (mutually - exclusive with Branch and Tag) - minLength: 1 - type: string - path: - default: registry.json - description: Path is the path to the registry file within - the repository - pattern: ^.*\.json$ - type: string - repository: - description: Repository is the Git repository URL (HTTP/HTTPS/SSH) - minLength: 1 - pattern: ^(file:///|https?://|git@|ssh://|git://).* - type: string - tag: - description: Tag is the Git tag to use (mutually exclusive - with Branch and Commit) - minLength: 1 - type: string - required: - - repository - type: object - kubernetes: - description: |- - Kubernetes defines a source that discovers MCP servers from running Kubernetes resources. - Mutually exclusive with ConfigMapRef, Git, API, URL, and Managed - properties: - namespaces: - description: |- - Namespaces is a list of Kubernetes namespaces to watch for MCP servers. - If empty, watches the operator's configured namespace. - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - managed: - description: |- - Managed defines a managed source that is directly manipulated via the registry API. - Managed sources do not sync from external sources. - At most one managed source is allowed per MCPRegistry. - Mutually exclusive with ConfigMapRef, Git, API, URL, and Kubernetes - type: object - name: - description: Name is a unique identifier for this source within - the MCPRegistry - minLength: 1 - type: string - syncPolicy: - description: |- - SyncPolicy defines the automatic synchronization behavior for this source. - If specified, enables automatic synchronization at the given interval. - Manual synchronization is always supported via annotation-based triggers - regardless of this setting. - Not applicable for Managed and Kubernetes sources (will be ignored). - properties: - interval: - description: |- - Interval is the sync interval for automatic synchronization (Go duration format) - Examples: "1h", "30m", "24h" - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - type: string - required: - - interval - type: object - url: - description: |- - URL defines a URL-hosted file source configuration. - The registry server fetches the registry data from the specified HTTP/HTTPS URL. - Mutually exclusive with ConfigMapRef, Git, API, Managed, and Kubernetes - properties: - endpoint: - description: |- - Endpoint is the HTTP/HTTPS URL to fetch the registry file from. - HTTPS is required unless the host is localhost. - minLength: 1 - pattern: ^https?://.* - type: string - timeout: - description: |- - Timeout is the timeout for HTTP requests (Go duration format). - Defaults to "30s" if not specified. - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - type: string - required: - - endpoint - type: object - required: - - name - type: object - x-kubernetes-validations: - - message: exactly one source type must be specified - rule: '(has(self.configMapRef) ? 1 : 0) + (has(self.git) ? 1 : - 0) + (has(self.api) ? 1 : 0) + (has(self.url) ? 1 : 0) + (has(self.managed) - ? 1 : 0) + (has(self.kubernetes) ? 1 : 0) == 1' - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - telemetryConfig: - description: |- - TelemetryConfig defines OpenTelemetry configuration for the registry API server. - When enabled, the server exports traces and metrics via OTLP. - Deprecated: Put telemetry config in configYAML instead. - properties: - enabled: - default: false - description: |- - Enabled controls whether telemetry is enabled globally. - When false, no telemetry providers are initialized. - type: boolean - endpoint: - description: |- - Endpoint is the OTLP collector endpoint (host:port). - Defaults to "localhost:4318" if not specified. - type: string - insecure: - default: false - description: |- - Insecure allows HTTP connections instead of HTTPS to the OTLP endpoint. - Should only be true for development/testing environments. - type: boolean - metrics: - description: Metrics defines metrics-specific configuration. - properties: - enabled: - default: false - description: Enabled controls whether metrics collection is - enabled. - type: boolean - type: object - serviceName: - description: |- - ServiceName is the name of the service for telemetry identification. - Defaults to "thv-registry-api" if not specified. - type: string - serviceVersion: - description: ServiceVersion is the version of the service for - telemetry identification. - type: string - tracing: - description: Tracing defines tracing-specific configuration. - properties: - enabled: - default: false - description: Enabled controls whether tracing is enabled. - type: boolean - sampling: - description: |- - Sampling controls the trace sampling rate (0.0 to 1.0, exclusive of 0.0). - 1.0 means sample all traces, 0.5 means sample 50%. - Defaults to 0.05 (5%) if not specified. - type: string - type: object - type: object volumeMounts: description: |- VolumeMounts defines additional volume mounts for the registry-api container. Each entry is a standard Kubernetes VolumeMount object (JSON/YAML). The operator appends them to the container's volume mounts alongside the config mount. - Only used when configYAML is set. Mount paths must match the file paths referenced in configYAML. For example, if configYAML references passwordFile: /secrets/git-creds/token, @@ -936,7 +156,6 @@ spec: Volumes defines additional volumes to add to the registry API pod. Each entry is a standard Kubernetes Volume object (JSON/YAML). The operator appends them to the pod spec alongside its own config volume. - Only used when configYAML is set. Use these to mount: - Secrets (git auth tokens, OAuth client secrets, CA certs) @@ -948,6 +167,8 @@ spec: type: array x-kubernetes-list-type: atomic x-kubernetes-preserve-unknown-fields: true + required: + - configYAML type: object status: description: MCPRegistryStatus defines the observed state of MCPRegistry @@ -1039,13 +260,6 @@ spec: type: string type: object type: object - x-kubernetes-validations: - - message: either configYAML or sources must be specified - rule: size(self.spec.configYAML) > 0 || (has(self.spec.sources) && size(self.spec.sources) - > 0) - - message: at most one managed source is allowed - rule: '!has(self.spec.sources) || self.spec.sources.filter(s, has(s.managed)).size() - <= 1' served: true storage: true subresources: diff --git a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml index 05bb687ea0..df4aeb28b6 100644 --- a/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml +++ b/deploy/charts/operator-crds/templates/toolhive.stacklok.dev_mcpregistries.yaml @@ -63,432 +63,23 @@ spec: spec: description: MCPRegistrySpec defines the desired state of MCPRegistry properties: - authConfig: - description: |- - AuthConfig defines the authentication configuration for the registry API server. - If not specified, defaults to anonymous authentication. - Deprecated: Put auth config in configYAML instead. - properties: - authz: - description: Authz defines authorization configuration for role-based - access control. - properties: - roles: - description: |- - Roles defines the role-based authorization rules. - Each role is a list of claim matchers (JSON objects with string or []string values). - properties: - manageEntries: - description: ManageEntries grants permission to create, - update, and delete registry entries. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - manageRegistries: - description: ManageRegistries grants permission to create, - update, and delete registries. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - manageSources: - description: ManageSources grants permission to create, - update, and delete sources. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - superAdmin: - description: SuperAdmin grants full administrative access - to the registry. - items: - x-kubernetes-preserve-unknown-fields: true - type: array - x-kubernetes-list-type: atomic - type: object - type: object - mode: - default: anonymous - description: |- - Mode specifies the authentication mode (anonymous or oauth) - Defaults to "anonymous" if not specified. - Use "oauth" to enable OAuth/OIDC authentication. - enum: - - anonymous - - oauth - type: string - oauth: - description: |- - OAuth defines OAuth/OIDC specific authentication settings - Only used when Mode is "oauth" - properties: - providers: - description: |- - Providers defines the OAuth/OIDC providers for authentication - Multiple providers can be configured (e.g., Kubernetes + external IDP) - items: - description: MCPRegistryOAuthProviderConfig defines configuration - for an OAuth/OIDC provider - properties: - allowPrivateIP: - default: false - description: |- - AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses - Required when the OAuth provider (e.g., Kubernetes API server) is running on a private network - Example: Set to true when using https://kubernetes.default.svc as the issuer URL - type: boolean - audience: - description: |- - Audience is the expected audience claim in the token (REQUIRED) - Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience - For Kubernetes, this is typically the API server URL - minLength: 1 - type: string - authTokenFile: - description: |- - AuthTokenFile is the path to a file containing a bearer token for authenticating to OIDC/JWKS endpoints. - Useful when the OIDC discovery or JWKS endpoint requires authentication. - Example: /var/run/secrets/kubernetes.io/serviceaccount/token - type: string - authTokenRef: - description: |- - AuthTokenRef is a reference to a Secret containing a bearer token for authenticating - to OIDC/JWKS endpoints. Useful when the OIDC discovery or JWKS endpoint requires authentication. - Example: ServiceAccount token for Kubernetes API server - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - caCertPath: - description: |- - CaCertPath is the path to the CA certificate bundle for verifying the provider's TLS certificate. - Required for Kubernetes in-cluster authentication or self-signed certificates - type: string - caCertRef: - description: |- - CACertRef is a reference to a ConfigMap containing the CA certificate bundle - for verifying the provider's TLS certificate. - Required for Kubernetes in-cluster authentication or self-signed certificates - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its - key must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - clientId: - description: ClientID is the OAuth client ID for token - introspection (optional) - type: string - clientSecretRef: - description: |- - ClientSecretRef is a reference to a Secret containing the client secret - The secret should have a key "clientSecret" containing the secret value - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - introspectionUrl: - description: |- - IntrospectionURL is the OAuth 2.0 Token Introspection endpoint (RFC 7662) - Used for validating opaque (non-JWT) tokens - If not specified, only JWT tokens can be validated via JWKS - pattern: ^https?://.* - type: string - issuerUrl: - description: |- - IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com) - The JWKS URL will be discovered automatically from .well-known/openid-configuration - unless JwksUrl is explicitly specified - minLength: 1 - pattern: ^https?://.* - type: string - jwksUrl: - description: |- - JwksUrl is the URL to fetch the JSON Web Key Set (JWKS) from - If specified, OIDC discovery is skipped and this URL is used directly - Example: https://kubernetes.default.svc/openid/v1/jwks - pattern: ^https?://.* - type: string - name: - description: Name is a unique identifier for this provider - (e.g., "kubernetes", "keycloak") - minLength: 1 - type: string - required: - - audience - - issuerUrl - - name - type: object - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - realm: - description: |- - Realm is the protection space identifier for WWW-Authenticate header (RFC 7235) - Defaults to "mcp-registry" if not specified - type: string - resourceUrl: - description: |- - ResourceURL is the URL identifying this protected resource (RFC 9728) - Used in the /.well-known/oauth-protected-resource endpoint - type: string - scopesSupported: - description: |- - ScopesSupported defines the OAuth scopes supported by this resource (RFC 9728) - Defaults to ["mcp-registry:read", "mcp-registry:write"] if not specified - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - publicPaths: - description: |- - PublicPaths defines additional paths that bypass authentication. - These extend the default public paths (health, docs, swagger, well-known). - Each path must start with "/". Do not add API data paths here. - Example: ["/custom/public", "/metrics"] - items: - minLength: 1 - pattern: ^/ - type: string - type: array - x-kubernetes-list-type: atomic - type: object - x-kubernetes-validations: - - message: authz configuration has no effect when auth mode is anonymous - rule: self.mode != 'anonymous' || !has(self.authz) configYAML: description: |- ConfigYAML is the complete registry server config.yaml content. The operator creates a ConfigMap from this string and mounts it at /config/config.yaml in the registry-api container. - The operator does NOT parse, validate, or transform this content. + The operator does NOT parse, validate, or transform this content — + configuration validation is the registry server's responsibility. - Mutually exclusive with the legacy typed fields (Sources, Registries, - DatabaseConfig, AuthConfig, TelemetryConfig). When set, the operator - uses the decoupled code path — volumes and mounts must be provided - via the Volumes and VolumeMounts fields below. + Security note: this content is stored in a ConfigMap, not a Secret. + Do not inline credentials (passwords, tokens, client secrets) in this + field. Instead, reference credentials via file paths and mount the + actual secrets using the Volumes and VolumeMounts fields. For database + passwords, use PGPassSecretRef. + minLength: 1 type: string - databaseConfig: - description: |- - DatabaseConfig defines the PostgreSQL database configuration for the registry API server. - If not specified, defaults will be used: - - Host: "postgres" - - Port: 5432 - - User: "db_app" - - MigrationUser: "db_migrator" - - Database: "registry" - - SSLMode: "prefer" - - MaxOpenConns: 10 - - MaxIdleConns: 2 - - ConnMaxLifetime: "30m" - - Deprecated: Put database config in configYAML and use pgpassSecretRef. - properties: - connMaxLifetime: - default: 30m - description: |- - ConnMaxLifetime is the maximum amount of time a connection may be reused (Go duration format) - Examples: "30m", "1h", "24h" - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - type: string - database: - default: registry - description: Database is the database name - type: string - dbAppUserPasswordSecretRef: - description: |- - DBAppUserPasswordSecretRef references a Kubernetes Secret containing the password for the application database user. - The operator will use this password along with DBMigrationUserPasswordSecretRef to generate a pgpass file - that is mounted to the registry API container. - properties: - key: - description: The key of the secret to select from. Must be - a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key must be - defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - dbMigrationUserPasswordSecretRef: - description: |- - DBMigrationUserPasswordSecretRef references a Kubernetes Secret containing the password for the migration database user. - The operator will use this password along with DBAppUserPasswordSecretRef to generate a pgpass file - that is mounted to the registry API container. - properties: - key: - description: The key of the secret to select from. Must be - a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key must be - defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - dynamicAuth: - description: |- - DynamicAuth defines dynamic database authentication configuration. - When set, the registry server authenticates to the database using - short-lived credentials instead of static passwords. - properties: - awsRdsIam: - description: AWSRDSIAM enables AWS RDS IAM authentication - for database connections. - properties: - region: - description: |- - Region is the AWS region for RDS IAM authentication. - Use "detect" to automatically detect the region from instance metadata. - minLength: 1 - type: string - type: object - type: object - host: - default: postgres - description: Host is the database server hostname - type: string - maxIdleConns: - default: 2 - description: MaxIdleConns is the maximum number of idle connections - in the pool - format: int32 - minimum: 0 - type: integer - maxMetaSize: - description: |- - MaxMetaSize is the maximum allowed size in bytes for publisher-provided - metadata extensions (_meta). Must be greater than zero. - Defaults to 262144 (256KB) if not specified. - format: int32 - minimum: 1 - type: integer - maxOpenConns: - default: 10 - description: MaxOpenConns is the maximum number of open connections - to the database - format: int32 - minimum: 1 - type: integer - migrationUser: - default: db_migrator - description: |- - MigrationUser is the migration user (elevated privileges: CREATE, ALTER, DROP) - Used for running database schema migrations - Credentials should be provided via pgpass file or environment variables - type: string - port: - default: 5432 - description: Port is the database server port - format: int32 - maximum: 65535 - minimum: 1 - type: integer - sslMode: - default: prefer - description: |- - SSLMode is the SSL mode for the connection - Valid values: disable, allow, prefer, require, verify-ca, verify-full - enum: - - disable - - allow - - prefer - - require - - verify-ca - - verify-full - type: string - user: - default: db_app - description: |- - User is the application user (limited privileges: SELECT, INSERT, UPDATE, DELETE) - Credentials should be provided via pgpass file or environment variables - type: string - required: - - dbAppUserPasswordSecretRef - - dbMigrationUserPasswordSecretRef - type: object displayName: - description: |- - DisplayName is a human-readable name for the registry. - Works with both the new configYAML path and the legacy typed path. + description: DisplayName is a human-readable name for the registry. type: string enforceServers: default: false @@ -501,16 +92,15 @@ spec: type: boolean pgpassSecretRef: description: "PGPassSecretRef references a Secret containing a pre-created - pgpass file.\nOnly used when configYAML is set. Mutually exclusive - with DatabaseConfig.\n\nWhy this is a dedicated field instead of - a regular volume/volumeMount:\nPostgreSQL's libpq rejects pgpass - files that aren't mode 0600. Kubernetes\nsecret volumes mount files - as root-owned, and the registry-api container\nruns as non-root - (UID 65532). A root-owned 0600 file is unreadable by\nUID 65532, - and using fsGroup changes permissions to 0640 which libpq also\nrejects. - The only solution is an init container that copies the file to an\nemptyDir + pgpass file.\n\nWhy this is a dedicated field instead of a regular + volume/volumeMount:\nPostgreSQL's libpq rejects pgpass files that + aren't mode 0600. Kubernetes\nsecret volumes mount files as root-owned, + and the registry-api container\nruns as non-root (UID 65532). A + root-owned 0600 file is unreadable by\nUID 65532, and using fsGroup + changes permissions to 0640 which libpq also\nrejects. The only + solution is an init container that copies the file to an\nemptyDir as the app user and runs chmod 0600. This cannot be expressed\nthrough - volumes/volumeMounts alone — it requires an init container, two\nextra + volumes/volumeMounts alone -- it requires an init container, two\nextra volumes (secret + emptyDir), a subPath mount, and an environment\nvariable, all wired together correctly.\n\nWhen specified, the operator generates all of that plumbing invisibly.\nThe user creates the Secret with @@ -548,383 +138,13 @@ spec: Note that to modify the specific container the registry API server runs in, you must specify the `registry-api` container name in the PodTemplateSpec. This field accepts a PodTemplateSpec object as JSON/YAML. - Works with both the new configYAML path and the legacy typed path. type: object x-kubernetes-preserve-unknown-fields: true - registries: - description: |- - Registries defines lightweight registry views that aggregate one or more sources. - Each registry references sources by name and can optionally gate access via claims. - Deprecated: Use configYAML with volumes/volumeMounts instead. - items: - description: MCPRegistryViewConfig defines a lightweight registry - view that aggregates one or more sources. - properties: - claims: - description: |- - Claims are key-value pairs that gate access to this registry view. - Only requests with matching claims can access this registry. Values must be string or []string. - type: object - x-kubernetes-preserve-unknown-fields: true - name: - description: Name is a unique identifier for this registry view - minLength: 1 - type: string - sources: - description: |- - Sources is an ordered list of source names that feed this registry. - Each name must reference a source defined in spec.sources. - items: - type: string - minItems: 1 - type: array - x-kubernetes-list-type: atomic - required: - - name - - sources - type: object - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - sources: - description: |- - Sources defines the data source configurations for the registry. - Each source defines where registry data comes from (Git, API, ConfigMap, URL, Managed, or Kubernetes). - Deprecated: Use configYAML with volumes/volumeMounts instead. - items: - description: |- - MCPRegistrySourceConfig defines a data source configuration for the registry. - Exactly one source type must be specified (ConfigMapRef, Git, API, URL, Managed, or Kubernetes). - properties: - api: - description: |- - API defines the API source configuration - Mutually exclusive with ConfigMapRef, Git, URL, Managed, and Kubernetes - properties: - endpoint: - description: |- - Endpoint is the base API URL (without path) - The controller will append the appropriate paths: - Phase 1 (ToolHive API): - - /v0/servers - List all servers (single response, no pagination) - - /v0/servers/{name} - Get specific server (future) - - /v0/info - Get registry metadata (future) - Example: "http://my-registry-api.default.svc.cluster.local/api" - minLength: 1 - pattern: ^https?://.* - type: string - required: - - endpoint - type: object - claims: - description: |- - Claims are key-value pairs attached to this source for authorization purposes. - All entries from this source inherit these claims. Values must be string or []string. - type: object - x-kubernetes-preserve-unknown-fields: true - configMapRef: - description: |- - ConfigMapRef defines the ConfigMap source configuration - Mutually exclusive with Git, API, URL, Managed, and Kubernetes - properties: - key: - description: The key to select. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the ConfigMap or its key must - be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - filter: - description: |- - Filter defines include/exclude patterns for registry content. - Not applicable for Managed and Kubernetes sources (will be ignored). - properties: - names: - description: NameFilters defines name-based filtering - properties: - exclude: - description: Exclude is a list of glob patterns to exclude - items: - type: string - type: array - x-kubernetes-list-type: atomic - include: - description: Include is a list of glob patterns to include - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - tags: - description: Tags defines tag-based filtering - properties: - exclude: - description: Exclude is a list of tags to exclude - items: - type: string - type: array - x-kubernetes-list-type: atomic - include: - description: Include is a list of tags to include - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - type: object - format: - default: toolhive - description: Format is the data format (toolhive, upstream) - enum: - - toolhive - - upstream - type: string - git: - description: |- - Git defines the Git repository source configuration - Mutually exclusive with ConfigMapRef, API, URL, Managed, and Kubernetes - properties: - auth: - description: |- - Auth defines optional authentication for private Git repositories. - When specified, enables HTTP Basic authentication using the provided - username and password/token from a Kubernetes Secret. - properties: - passwordSecretRef: - description: |- - PasswordSecretRef references a Kubernetes Secret containing the password or token - for Git authentication. The secret value will be mounted as a file and its path - passed to the registry server via the git.auth.passwordFile configuration. - - Example secret: - apiVersion: v1 - kind: Secret - metadata: - name: git-credentials - stringData: - token: - - Then reference it as: - passwordSecretRef: - name: git-credentials - key: token - properties: - key: - description: The key of the secret to select from. Must - be a valid secret key. - type: string - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - optional: - description: Specify whether the Secret or its key - must be defined - type: boolean - required: - - key - type: object - x-kubernetes-map-type: atomic - username: - description: |- - Username is the Git username for HTTP Basic authentication. - For GitHub/GitLab token-based auth, this is typically the literal string "git" - or the token itself depending on the provider. - minLength: 1 - type: string - required: - - passwordSecretRef - - username - type: object - branch: - description: Branch is the Git branch to use (mutually exclusive - with Tag and Commit) - minLength: 1 - type: string - commit: - description: Commit is the Git commit SHA to use (mutually - exclusive with Branch and Tag) - minLength: 1 - type: string - path: - default: registry.json - description: Path is the path to the registry file within - the repository - pattern: ^.*\.json$ - type: string - repository: - description: Repository is the Git repository URL (HTTP/HTTPS/SSH) - minLength: 1 - pattern: ^(file:///|https?://|git@|ssh://|git://).* - type: string - tag: - description: Tag is the Git tag to use (mutually exclusive - with Branch and Commit) - minLength: 1 - type: string - required: - - repository - type: object - kubernetes: - description: |- - Kubernetes defines a source that discovers MCP servers from running Kubernetes resources. - Mutually exclusive with ConfigMapRef, Git, API, URL, and Managed - properties: - namespaces: - description: |- - Namespaces is a list of Kubernetes namespaces to watch for MCP servers. - If empty, watches the operator's configured namespace. - items: - type: string - type: array - x-kubernetes-list-type: atomic - type: object - managed: - description: |- - Managed defines a managed source that is directly manipulated via the registry API. - Managed sources do not sync from external sources. - At most one managed source is allowed per MCPRegistry. - Mutually exclusive with ConfigMapRef, Git, API, URL, and Kubernetes - type: object - name: - description: Name is a unique identifier for this source within - the MCPRegistry - minLength: 1 - type: string - syncPolicy: - description: |- - SyncPolicy defines the automatic synchronization behavior for this source. - If specified, enables automatic synchronization at the given interval. - Manual synchronization is always supported via annotation-based triggers - regardless of this setting. - Not applicable for Managed and Kubernetes sources (will be ignored). - properties: - interval: - description: |- - Interval is the sync interval for automatic synchronization (Go duration format) - Examples: "1h", "30m", "24h" - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - type: string - required: - - interval - type: object - url: - description: |- - URL defines a URL-hosted file source configuration. - The registry server fetches the registry data from the specified HTTP/HTTPS URL. - Mutually exclusive with ConfigMapRef, Git, API, Managed, and Kubernetes - properties: - endpoint: - description: |- - Endpoint is the HTTP/HTTPS URL to fetch the registry file from. - HTTPS is required unless the host is localhost. - minLength: 1 - pattern: ^https?://.* - type: string - timeout: - description: |- - Timeout is the timeout for HTTP requests (Go duration format). - Defaults to "30s" if not specified. - pattern: ^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$ - type: string - required: - - endpoint - type: object - required: - - name - type: object - x-kubernetes-validations: - - message: exactly one source type must be specified - rule: '(has(self.configMapRef) ? 1 : 0) + (has(self.git) ? 1 : - 0) + (has(self.api) ? 1 : 0) + (has(self.url) ? 1 : 0) + (has(self.managed) - ? 1 : 0) + (has(self.kubernetes) ? 1 : 0) == 1' - maxItems: 20 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map - telemetryConfig: - description: |- - TelemetryConfig defines OpenTelemetry configuration for the registry API server. - When enabled, the server exports traces and metrics via OTLP. - Deprecated: Put telemetry config in configYAML instead. - properties: - enabled: - default: false - description: |- - Enabled controls whether telemetry is enabled globally. - When false, no telemetry providers are initialized. - type: boolean - endpoint: - description: |- - Endpoint is the OTLP collector endpoint (host:port). - Defaults to "localhost:4318" if not specified. - type: string - insecure: - default: false - description: |- - Insecure allows HTTP connections instead of HTTPS to the OTLP endpoint. - Should only be true for development/testing environments. - type: boolean - metrics: - description: Metrics defines metrics-specific configuration. - properties: - enabled: - default: false - description: Enabled controls whether metrics collection is - enabled. - type: boolean - type: object - serviceName: - description: |- - ServiceName is the name of the service for telemetry identification. - Defaults to "thv-registry-api" if not specified. - type: string - serviceVersion: - description: ServiceVersion is the version of the service for - telemetry identification. - type: string - tracing: - description: Tracing defines tracing-specific configuration. - properties: - enabled: - default: false - description: Enabled controls whether tracing is enabled. - type: boolean - sampling: - description: |- - Sampling controls the trace sampling rate (0.0 to 1.0, exclusive of 0.0). - 1.0 means sample all traces, 0.5 means sample 50%. - Defaults to 0.05 (5%) if not specified. - type: string - type: object - type: object volumeMounts: description: |- VolumeMounts defines additional volume mounts for the registry-api container. Each entry is a standard Kubernetes VolumeMount object (JSON/YAML). The operator appends them to the container's volume mounts alongside the config mount. - Only used when configYAML is set. Mount paths must match the file paths referenced in configYAML. For example, if configYAML references passwordFile: /secrets/git-creds/token, @@ -939,7 +159,6 @@ spec: Volumes defines additional volumes to add to the registry API pod. Each entry is a standard Kubernetes Volume object (JSON/YAML). The operator appends them to the pod spec alongside its own config volume. - Only used when configYAML is set. Use these to mount: - Secrets (git auth tokens, OAuth client secrets, CA certs) @@ -951,6 +170,8 @@ spec: type: array x-kubernetes-list-type: atomic x-kubernetes-preserve-unknown-fields: true + required: + - configYAML type: object status: description: MCPRegistryStatus defines the observed state of MCPRegistry @@ -1042,13 +263,6 @@ spec: type: string type: object type: object - x-kubernetes-validations: - - message: either configYAML or sources must be specified - rule: size(self.spec.configYAML) > 0 || (has(self.spec.sources) && size(self.spec.sources) - > 0) - - message: at most one managed source is allowed - rule: '!has(self.spec.sources) || self.spec.sources.filter(s, has(s.managed)).size() - <= 1' served: true storage: true subresources: diff --git a/docs/arch/06-registry-system.md b/docs/arch/06-registry-system.md index 6017f54559..a7ddfdfdfa 100644 --- a/docs/arch/06-registry-system.md +++ b/docs/arch/06-registry-system.md @@ -514,6 +514,18 @@ For Kubernetes deployments, registries managed via `MCPRegistry` CRD. **Implementation**: `cmd/thv-operator/api/v1alpha1/mcpregistry_types.go` +### How configYAML Works + +The MCPRegistry CRD uses a `configYAML` field that contains the complete +[ToolHive Registry Server](https://github.com/stacklok/toolhive-registry-server) +`config.yaml` verbatim. The operator passes this content through to the +registry server without parsing or transforming it -- configuration +validation is the registry server's responsibility. + +Any files referenced in `configYAML` (registry data, Git credentials, TLS +certs) must be mounted into the registry-api container via explicit +`volumes` and `volumeMounts` fields on the CRD. + ### Example CRD ```yaml @@ -521,28 +533,59 @@ apiVersion: toolhive.stacklok.dev/v1alpha1 kind: MCPRegistry metadata: name: company-registry + namespace: toolhive-system spec: - source: - type: git - git: - repository: https://github.com/company/mcp-registry - branch: main - path: registry.json - syncPolicy: - interval: 1h + configYAML: | + sources: + - name: company-repo + format: toolhive + git: + repository: https://github.com/company/mcp-registry + branch: main + path: registry.json + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["company-repo"] + database: + host: registry-db-rw + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` ### Source Types +Sources are defined inside `configYAML`. The registry server supports +several source types; the most common are Git, file (ConfigMap-backed), +and Kubernetes. + #### Git Source ```yaml -source: - type: git - git: - repository: https://github.com/example/registry - branch: main - path: registry.json +configYAML: | + sources: + - name: my-source + format: toolhive + git: + repository: https://github.com/example/registry + branch: main + path: registry.json + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["my-source"] + database: + host: postgres + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous ``` **Features:** @@ -553,68 +596,127 @@ source: **Private Repository Authentication:** -```yaml -registries: - - name: default - format: toolhive - git: - repository: https://github.com/org/private-registry - branch: main - path: registry.json - auth: - username: "git" # Use "git" for GitHub PATs - passwordSecretRef: - name: git-credentials - key: password -``` +Git credentials are mounted as files using `volumes`/`volumeMounts` and +referenced via `passwordFile` in the source configuration. -The password is stored in a Kubernetes Secret and mounted securely in the registry-api pod. - -**Implementation**: `cmd/thv-operator/pkg/sources/git.go` +```yaml +spec: + configYAML: | + sources: + - name: private-repo + format: toolhive + git: + repository: https://github.com/org/private-registry + branch: main + path: registry.json + auth: + username: "git" # Use "git" for GitHub PATs + passwordFile: /secrets/git-credentials/token + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["private-repo"] + database: + host: postgres + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: git-auth-credentials + secret: + secretName: git-credentials + items: + - key: token + path: token + volumeMounts: + - name: git-auth-credentials + mountPath: /secrets/git-credentials + readOnly: true +``` + +The password Secret is mounted explicitly into the registry-api pod via +the `volumes` and `volumeMounts` fields. The `passwordFile` path in +`configYAML` must match the `mountPath`. + +**Implementation**: `cmd/thv-operator/pkg/registryapi/` #### ConfigMap Source +Registry data from a ConfigMap is served by using a `file:` source in +`configYAML` and mounting the ConfigMap with `volumes`/`volumeMounts`. + ```yaml -source: - type: configmap - configMapRef: - name: mcp-registry-data - key: registry.json +spec: + configYAML: | + sources: + - name: production + format: toolhive + file: + path: /config/registry/production/registry.json + syncPolicy: + interval: 1h + registries: + - name: default + sources: ["production"] + database: + host: postgres + port: 5432 + user: db_app + database: registry + auth: + mode: anonymous + volumes: + - name: registry-data-production + configMap: + name: mcp-registry-data + items: + - key: registry.json + path: registry.json + volumeMounts: + - name: registry-data-production + mountPath: /config/registry/production + readOnly: true ``` **Features:** - Native Kubernetes resource - Direct updates via kubectl - No external dependencies +- File path in `configYAML` must match the `mountPath` -**Implementation**: `cmd/thv-operator/pkg/sources/configmap.go` +**Implementation**: `cmd/thv-operator/pkg/registryapi/` ### Sync Policy -**Automatic sync:** +Sync intervals are configured per-source inside `configYAML`: + ```yaml -syncPolicy: - interval: 1h +configYAML: | + sources: + - name: my-source + format: toolhive + git: + repository: https://github.com/example/registry + branch: main + path: registry.json + syncPolicy: + interval: 1h ``` -**Manual sync only:** - -Omit the `syncPolicy` field entirely. Manual sync can be triggered: - -```bash -kubectl annotate mcpregistry company-registry \ - toolhive.stacklok.dev/sync-trigger=true -``` +Omit the `syncPolicy` block on a source for manual-only sync. **Implementation**: `cmd/thv-operator/controllers/mcpregistry_controller.go` ### API Service -When `apiService.enabled: true`, operator creates: +The operator always creates a registry API deployment for each MCPRegistry: 1. **Deployment**: Running [ToolHive Registry Server](https://github.com/stacklok/toolhive-registry-server) (image: `ghcr.io/stacklok/thv-registry-api`) 2. **Service**: Exposing API endpoints -3. **ConfigMap**: Containing registry data +3. **ConfigMap**: Containing the `configYAML` content mounted at `/config/config.yaml` **Access:** ```bash @@ -626,7 +728,7 @@ kubectl port-forward svc/company-registry-api 8080:8080 curl http://localhost:8080/api/v1/registry ``` -**Implementation**: `cmd/thv-operator/pkg/registryapi/service.go` +**Implementation**: `cmd/thv-operator/pkg/registryapi/` ### Status Management diff --git a/docs/operator/crd-api.md b/docs/operator/crd-api.md index 88077fc7b1..b32f2c0c85 100644 --- a/docs/operator/crd-api.md +++ b/docs/operator/crd-api.md @@ -760,24 +760,6 @@ _Appears in:_ -#### api.v1alpha1.APISource - - - -APISource defines API source configuration for ToolHive Registry APIs -Phase 1: Supports ToolHive API endpoints (no pagination) -Phase 2: Will add support for upstream MCP Registry API with pagination - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `endpoint` _string_ | Endpoint is the base API URL (without path)
The controller will append the appropriate paths:
Phase 1 (ToolHive API):
- /v0/servers - List all servers (single response, no pagination)
- /v0/servers/\{name\} - Get specific server (future)
- /v0/info - Get registry metadata (future)
Example: "http://my-registry-api.default.svc.cluster.local/api" | | MinLength: 1
Pattern: `^https?://.*`
Required: \{\}
| - - #### api.v1alpha1.AWSStsConfig @@ -1234,47 +1216,6 @@ _Appears in:_ | `upstreamInject` | ExternalAuthTypeUpstreamInject is the type for upstream token injection
This injects an upstream IDP access token as the Authorization: Bearer header
| -#### api.v1alpha1.GitAuthConfig - - - -GitAuthConfig defines authentication settings for private Git repositories. -Uses HTTP Basic authentication with username and password/token. -The password is stored in a Kubernetes Secret and mounted as a file -for the registry server to read. - - - -_Appears in:_ -- [api.v1alpha1.GitSource](#apiv1alpha1gitsource) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `username` _string_ | Username is the Git username for HTTP Basic authentication.
For GitHub/GitLab token-based auth, this is typically the literal string "git"
or the token itself depending on the provider. | | MinLength: 1
Required: \{\}
| -| `passwordSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | PasswordSecretRef references a Kubernetes Secret containing the password or token
for Git authentication. The secret value will be mounted as a file and its path
passed to the registry server via the git.auth.passwordFile configuration.
Example secret:
apiVersion: v1
kind: Secret
metadata:
name: git-credentials
stringData:
token:
Then reference it as:
passwordSecretRef:
name: git-credentials
key: token | | Required: \{\}
| - - -#### api.v1alpha1.GitSource - - - -GitSource defines Git repository source configuration - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `repository` _string_ | Repository is the Git repository URL (HTTP/HTTPS/SSH) | | MinLength: 1
Pattern: `^(file:///\|https?://\|git@\|ssh://\|git://).*`
Required: \{\}
| -| `branch` _string_ | Branch is the Git branch to use (mutually exclusive with Tag and Commit) | | MinLength: 1
Optional: \{\}
| -| `tag` _string_ | Tag is the Git tag to use (mutually exclusive with Branch and Commit) | | MinLength: 1
Optional: \{\}
| -| `commit` _string_ | Commit is the Git commit SHA to use (mutually exclusive with Branch and Tag) | | MinLength: 1
Optional: \{\}
| -| `path` _string_ | Path is the path to the registry file within the repository | registry.json | Pattern: `^.*\.json$`
Optional: \{\}
| -| `auth` _[api.v1alpha1.GitAuthConfig](#apiv1alpha1gitauthconfig)_ | Auth defines optional authentication for private Git repositories.
When specified, enables HTTP Basic authentication using the provided
username and password/token from a Kubernetes Secret. | | Optional: \{\}
| - - #### api.v1alpha1.HeaderForwardConfig @@ -1463,23 +1404,6 @@ _Appears in:_ | `useClusterAuth` _boolean_ | UseClusterAuth enables using the Kubernetes cluster's CA bundle and service account token.
When true, uses /var/run/secrets/kubernetes.io/serviceaccount/ca.crt for TLS verification
and /var/run/secrets/kubernetes.io/serviceaccount/token for bearer token authentication.
Defaults to true if not specified. | | Optional: \{\}
| -#### api.v1alpha1.KubernetesSource - - - -KubernetesSource defines a source that discovers MCP servers from running Kubernetes resources. -Per-entry claims can be set on CRDs via the toolhive.stacklok.dev/authz-claims JSON annotation. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `namespaces` _string array_ | Namespaces is a list of Kubernetes namespaces to watch for MCP servers.
If empty, watches the operator's configured namespace. | | Optional: \{\}
| - - #### api.v1alpha1.MCPExternalAuthConfig @@ -1813,119 +1737,6 @@ _Appears in:_ | `status` _[api.v1alpha1.MCPRegistryStatus](#apiv1alpha1mcpregistrystatus)_ | | | | -#### api.v1alpha1.MCPRegistryAWSRDSIAMConfig - - - -MCPRegistryAWSRDSIAMConfig defines AWS RDS IAM authentication configuration. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryDynamicAuthConfig](#apiv1alpha1mcpregistrydynamicauthconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `region` _string_ | Region is the AWS region for RDS IAM authentication.
Use "detect" to automatically detect the region from instance metadata. | | MinLength: 1
Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryAuthConfig - - - -MCPRegistryAuthConfig defines authentication configuration for the registry API server. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySpec](#apiv1alpha1mcpregistryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `mode` _[api.v1alpha1.MCPRegistryAuthMode](#apiv1alpha1mcpregistryauthmode)_ | Mode specifies the authentication mode (anonymous or oauth)
Defaults to "anonymous" if not specified.
Use "oauth" to enable OAuth/OIDC authentication. | anonymous | Enum: [anonymous oauth]
Optional: \{\}
| -| `publicPaths` _string array_ | PublicPaths defines additional paths that bypass authentication.
These extend the default public paths (health, docs, swagger, well-known).
Each path must start with "/". Do not add API data paths here.
Example: ["/custom/public", "/metrics"] | | items:MinLength: 1
items:Pattern: `^/`
Optional: \{\}
| -| `oauth` _[api.v1alpha1.MCPRegistryOAuthConfig](#apiv1alpha1mcpregistryoauthconfig)_ | OAuth defines OAuth/OIDC specific authentication settings
Only used when Mode is "oauth" | | Optional: \{\}
| -| `authz` _[api.v1alpha1.MCPRegistryAuthzConfig](#apiv1alpha1mcpregistryauthzconfig)_ | Authz defines authorization configuration for role-based access control. | | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryAuthMode - -_Underlying type:_ _string_ - -MCPRegistryAuthMode represents the authentication mode for the registry API server - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryAuthConfig](#apiv1alpha1mcpregistryauthconfig) - -| Field | Description | -| --- | --- | -| `anonymous` | MCPRegistryAuthModeAnonymous allows unauthenticated access
| -| `oauth` | MCPRegistryAuthModeOAuth enables OAuth/OIDC authentication
| - - -#### api.v1alpha1.MCPRegistryAuthzConfig - - - -MCPRegistryAuthzConfig defines authorization configuration for role-based access control - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryAuthConfig](#apiv1alpha1mcpregistryauthconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `roles` _[api.v1alpha1.MCPRegistryRolesConfig](#apiv1alpha1mcpregistryrolesconfig)_ | Roles defines the role-based authorization rules.
Each role is a list of claim matchers (JSON objects with string or []string values). | | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryDatabaseConfig - - - -MCPRegistryDatabaseConfig defines PostgreSQL database configuration for the registry API server. -Uses a two-user security model: separate users for operations and migrations. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySpec](#apiv1alpha1mcpregistryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `host` _string_ | Host is the database server hostname | postgres | Optional: \{\}
| -| `port` _integer_ | Port is the database server port | 5432 | Maximum: 65535
Minimum: 1
Optional: \{\}
| -| `user` _string_ | User is the application user (limited privileges: SELECT, INSERT, UPDATE, DELETE)
Credentials should be provided via pgpass file or environment variables | db_app | Optional: \{\}
| -| `migrationUser` _string_ | MigrationUser is the migration user (elevated privileges: CREATE, ALTER, DROP)
Used for running database schema migrations
Credentials should be provided via pgpass file or environment variables | db_migrator | Optional: \{\}
| -| `database` _string_ | Database is the database name | registry | Optional: \{\}
| -| `sslMode` _string_ | SSLMode is the SSL mode for the connection
Valid values: disable, allow, prefer, require, verify-ca, verify-full | prefer | Enum: [disable allow prefer require verify-ca verify-full]
Optional: \{\}
| -| `maxOpenConns` _integer_ | MaxOpenConns is the maximum number of open connections to the database | 10 | Minimum: 1
Optional: \{\}
| -| `maxIdleConns` _integer_ | MaxIdleConns is the maximum number of idle connections in the pool | 2 | Minimum: 0
Optional: \{\}
| -| `connMaxLifetime` _string_ | ConnMaxLifetime is the maximum amount of time a connection may be reused (Go duration format)
Examples: "30m", "1h", "24h" | 30m | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
| -| `maxMetaSize` _integer_ | MaxMetaSize is the maximum allowed size in bytes for publisher-provided
metadata extensions (_meta). Must be greater than zero.
Defaults to 262144 (256KB) if not specified. | | Minimum: 1
Optional: \{\}
| -| `dynamicAuth` _[api.v1alpha1.MCPRegistryDynamicAuthConfig](#apiv1alpha1mcpregistrydynamicauthconfig)_ | DynamicAuth defines dynamic database authentication configuration.
When set, the registry server authenticates to the database using
short-lived credentials instead of static passwords. | | Optional: \{\}
| -| `dbAppUserPasswordSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | DBAppUserPasswordSecretRef references a Kubernetes Secret containing the password for the application database user.
The operator will use this password along with DBMigrationUserPasswordSecretRef to generate a pgpass file
that is mounted to the registry API container. | | Required: \{\}
| -| `dbMigrationUserPasswordSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | DBMigrationUserPasswordSecretRef references a Kubernetes Secret containing the password for the migration database user.
The operator will use this password along with DBAppUserPasswordSecretRef to generate a pgpass file
that is mounted to the registry API container. | | Required: \{\}
| - - -#### api.v1alpha1.MCPRegistryDynamicAuthConfig - - - -MCPRegistryDynamicAuthConfig defines dynamic database authentication configuration. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryDatabaseConfig](#apiv1alpha1mcpregistrydatabaseconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `awsRdsIam` _[api.v1alpha1.MCPRegistryAWSRDSIAMConfig](#apiv1alpha1mcpregistryawsrdsiamconfig)_ | AWSRDSIAM enables AWS RDS IAM authentication for database connections. | | Optional: \{\}
| - - #### api.v1alpha1.MCPRegistryList @@ -1946,68 +1757,6 @@ MCPRegistryList contains a list of MCPRegistry | `items` _[api.v1alpha1.MCPRegistry](#apiv1alpha1mcpregistry) array_ | | | | -#### api.v1alpha1.MCPRegistryMetricsConfig - - - -MCPRegistryMetricsConfig defines metrics-specific configuration. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryTelemetryConfig](#apiv1alpha1mcpregistrytelemetryconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `enabled` _boolean_ | Enabled controls whether metrics collection is enabled. | false | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryOAuthConfig - - - -MCPRegistryOAuthConfig defines OAuth/OIDC specific authentication settings - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryAuthConfig](#apiv1alpha1mcpregistryauthconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `resourceUrl` _string_ | ResourceURL is the URL identifying this protected resource (RFC 9728)
Used in the /.well-known/oauth-protected-resource endpoint | | Optional: \{\}
| -| `providers` _[api.v1alpha1.MCPRegistryOAuthProviderConfig](#apiv1alpha1mcpregistryoauthproviderconfig) array_ | Providers defines the OAuth/OIDC providers for authentication
Multiple providers can be configured (e.g., Kubernetes + external IDP) | | MinItems: 1
Optional: \{\}
| -| `scopesSupported` _string array_ | ScopesSupported defines the OAuth scopes supported by this resource (RFC 9728)
Defaults to ["mcp-registry:read", "mcp-registry:write"] if not specified | | Optional: \{\}
| -| `realm` _string_ | Realm is the protection space identifier for WWW-Authenticate header (RFC 7235)
Defaults to "mcp-registry" if not specified | | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryOAuthProviderConfig - - - -MCPRegistryOAuthProviderConfig defines configuration for an OAuth/OIDC provider - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryOAuthConfig](#apiv1alpha1mcpregistryoauthconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is a unique identifier for this provider (e.g., "kubernetes", "keycloak") | | MinLength: 1
Required: \{\}
| -| `issuerUrl` _string_ | IssuerURL is the OIDC issuer URL (e.g., https://accounts.google.com)
The JWKS URL will be discovered automatically from .well-known/openid-configuration
unless JwksUrl is explicitly specified | | MinLength: 1
Pattern: `^https?://.*`
Required: \{\}
| -| `jwksUrl` _string_ | JwksUrl is the URL to fetch the JSON Web Key Set (JWKS) from
If specified, OIDC discovery is skipped and this URL is used directly
Example: https://kubernetes.default.svc/openid/v1/jwks | | Pattern: `^https?://.*`
Optional: \{\}
| -| `audience` _string_ | Audience is the expected audience claim in the token (REQUIRED)
Per RFC 6749 Section 4.1.3, tokens must be validated against expected audience
For Kubernetes, this is typically the API server URL | | MinLength: 1
Required: \{\}
| -| `clientId` _string_ | ClientID is the OAuth client ID for token introspection (optional) | | Optional: \{\}
| -| `clientSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | ClientSecretRef is a reference to a Secret containing the client secret
The secret should have a key "clientSecret" containing the secret value | | Optional: \{\}
| -| `caCertRef` _[ConfigMapKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#configmapkeyselector-v1-core)_ | CACertRef is a reference to a ConfigMap containing the CA certificate bundle
for verifying the provider's TLS certificate.
Required for Kubernetes in-cluster authentication or self-signed certificates | | Optional: \{\}
| -| `caCertPath` _string_ | CaCertPath is the path to the CA certificate bundle for verifying the provider's TLS certificate.
Required for Kubernetes in-cluster authentication or self-signed certificates | | Optional: \{\}
| -| `authTokenRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | AuthTokenRef is a reference to a Secret containing a bearer token for authenticating
to OIDC/JWKS endpoints. Useful when the OIDC discovery or JWKS endpoint requires authentication.
Example: ServiceAccount token for Kubernetes API server | | Optional: \{\}
| -| `authTokenFile` _string_ | AuthTokenFile is the path to a file containing a bearer token for authenticating to OIDC/JWKS endpoints.
Useful when the OIDC discovery or JWKS endpoint requires authentication.
Example: /var/run/secrets/kubernetes.io/serviceaccount/token | | Optional: \{\}
| -| `introspectionUrl` _string_ | IntrospectionURL is the OAuth 2.0 Token Introspection endpoint (RFC 7662)
Used for validating opaque (non-JWT) tokens
If not specified, only JWT tokens can be validated via JWKS | | Pattern: `^https?://.*`
Optional: \{\}
| -| `allowPrivateIP` _boolean_ | AllowPrivateIP allows JWKS/OIDC endpoints on private IP addresses
Required when the OAuth provider (e.g., Kubernetes API server) is running on a private network
Example: Set to true when using https://kubernetes.default.svc as the issuer URL | false | Optional: \{\}
| - - #### api.v1alpha1.MCPRegistryPhase _Underlying type:_ _string_ @@ -2028,53 +1777,6 @@ _Appears in:_ | `Terminating` | MCPRegistryPhaseTerminating means the MCPRegistry is being deleted
| -#### api.v1alpha1.MCPRegistryRolesConfig - - - -MCPRegistryRolesConfig defines role-based authorization rules. -Each role is a list of claim matchers — a request matching any entry in the list is granted the role. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryAuthzConfig](#apiv1alpha1mcpregistryauthzconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `superAdmin` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | SuperAdmin grants full administrative access to the registry. | | Optional: \{\}
| -| `manageSources` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | ManageSources grants permission to create, update, and delete sources. | | Optional: \{\}
| -| `manageRegistries` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | ManageRegistries grants permission to create, update, and delete registries. | | Optional: \{\}
| -| `manageEntries` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | ManageEntries grants permission to create, update, and delete registry entries. | | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistrySourceConfig - - - -MCPRegistrySourceConfig defines a data source configuration for the registry. -Exactly one source type must be specified (ConfigMapRef, Git, API, URL, Managed, or Kubernetes). - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySpec](#apiv1alpha1mcpregistryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is a unique identifier for this source within the MCPRegistry | | MinLength: 1
Required: \{\}
| -| `format` _string_ | Format is the data format (toolhive, upstream) | toolhive | Enum: [toolhive upstream]
| -| `claims` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io)_ | Claims are key-value pairs attached to this source for authorization purposes.
All entries from this source inherit these claims. Values must be string or []string. | | Type: object
Optional: \{\}
| -| `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, URL, Managed, and Kubernetes | | Optional: \{\}
| -| `git` _[api.v1alpha1.GitSource](#apiv1alpha1gitsource)_ | Git defines the Git repository source configuration
Mutually exclusive with ConfigMapRef, API, URL, Managed, and Kubernetes | | Optional: \{\}
| -| `api` _[api.v1alpha1.APISource](#apiv1alpha1apisource)_ | API defines the API source configuration
Mutually exclusive with ConfigMapRef, Git, URL, Managed, and Kubernetes | | Optional: \{\}
| -| `url` _[api.v1alpha1.URLSource](#apiv1alpha1urlsource)_ | URL defines a URL-hosted file source configuration.
The registry server fetches the registry data from the specified HTTP/HTTPS URL.
Mutually exclusive with ConfigMapRef, Git, API, Managed, and Kubernetes | | Optional: \{\}
| -| `managed` _[api.v1alpha1.ManagedSource](#apiv1alpha1managedsource)_ | Managed defines a managed source that is directly manipulated via the registry API.
Managed sources do not sync from external sources.
At most one managed source is allowed per MCPRegistry.
Mutually exclusive with ConfigMapRef, Git, API, URL, and Kubernetes | | Optional: \{\}
| -| `kubernetes` _[api.v1alpha1.KubernetesSource](#apiv1alpha1kubernetessource)_ | Kubernetes defines a source that discovers MCP servers from running Kubernetes resources.
Mutually exclusive with ConfigMapRef, Git, API, URL, and Managed | | Optional: \{\}
| -| `syncPolicy` _[api.v1alpha1.SyncPolicy](#apiv1alpha1syncpolicy)_ | SyncPolicy defines the automatic synchronization behavior for this source.
If specified, enables automatic synchronization at the given interval.
Manual synchronization is always supported via annotation-based triggers
regardless of this setting.
Not applicable for Managed and Kubernetes sources (will be ignored). | | Optional: \{\}
| -| `filter` _[api.v1alpha1.RegistryFilter](#apiv1alpha1registryfilter)_ | Filter defines include/exclude patterns for registry content.
Not applicable for Managed and Kubernetes sources (will be ignored). | | Optional: \{\}
| - - #### api.v1alpha1.MCPRegistrySpec @@ -2088,18 +1790,13 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `configYAML` _string_ | ConfigYAML is the complete registry server config.yaml content.
The operator creates a ConfigMap from this string and mounts it
at /config/config.yaml in the registry-api container.
The operator does NOT parse, validate, or transform this content.
Mutually exclusive with the legacy typed fields (Sources, Registries,
DatabaseConfig, AuthConfig, TelemetryConfig). When set, the operator
uses the decoupled code path — volumes and mounts must be provided
via the Volumes and VolumeMounts fields below. | | Optional: \{\}
| -| `volumes` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | Volumes defines additional volumes to add to the registry API pod.
Each entry is a standard Kubernetes Volume object (JSON/YAML).
The operator appends them to the pod spec alongside its own config volume.
Only used when configYAML is set.
Use these to mount:
- Secrets (git auth tokens, OAuth client secrets, CA certs)
- ConfigMaps (registry data files)
- PersistentVolumeClaims (registry data on persistent storage)
- Any other volume type the registry server needs | | Optional: \{\}
| -| `volumeMounts` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | VolumeMounts defines additional volume mounts for the registry-api container.
Each entry is a standard Kubernetes VolumeMount object (JSON/YAML).
The operator appends them to the container's volume mounts alongside the config mount.
Only used when configYAML is set.
Mount paths must match the file paths referenced in configYAML.
For example, if configYAML references passwordFile: /secrets/git-creds/token,
a corresponding volume mount must exist with mountPath: /secrets/git-creds. | | Optional: \{\}
| -| `pgpassSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | PGPassSecretRef references a Secret containing a pre-created pgpass file.
Only used when configYAML is set. Mutually exclusive with DatabaseConfig.
Why this is a dedicated field instead of a regular volume/volumeMount:
PostgreSQL's libpq rejects pgpass files that aren't mode 0600. Kubernetes
secret volumes mount files as root-owned, and the registry-api container
runs as non-root (UID 65532). A root-owned 0600 file is unreadable by
UID 65532, and using fsGroup changes permissions to 0640 which libpq also
rejects. The only solution is an init container that copies the file to an
emptyDir as the app user and runs chmod 0600. This cannot be expressed
through volumes/volumeMounts alone — it requires an init container, two
extra volumes (secret + emptyDir), a subPath mount, and an environment
variable, all wired together correctly.
When specified, the operator generates all of that plumbing invisibly.
The user creates the Secret with pgpass-formatted content; the operator
handles only the Kubernetes permission mechanics.
Example Secret:
apiVersion: v1
kind: Secret
metadata:
name: my-pgpass
stringData:
.pgpass: \|
postgres:5432:registry:db_app:mypassword
postgres:5432:registry:db_migrator:otherpassword
Then reference it:
pgpassSecretRef:
name: my-pgpass
key: .pgpass | | Optional: \{\}
| -| `displayName` _string_ | DisplayName is a human-readable name for the registry.
Works with both the new configYAML path and the legacy typed path. | | Optional: \{\}
| +| `configYAML` _string_ | ConfigYAML is the complete registry server config.yaml content.
The operator creates a ConfigMap from this string and mounts it
at /config/config.yaml in the registry-api container.
The operator does NOT parse, validate, or transform this content —
configuration validation is the registry server's responsibility.
Security note: this content is stored in a ConfigMap, not a Secret.
Do not inline credentials (passwords, tokens, client secrets) in this
field. Instead, reference credentials via file paths and mount the
actual secrets using the Volumes and VolumeMounts fields. For database
passwords, use PGPassSecretRef. | | MinLength: 1
Required: \{\}
| +| `volumes` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | Volumes defines additional volumes to add to the registry API pod.
Each entry is a standard Kubernetes Volume object (JSON/YAML).
The operator appends them to the pod spec alongside its own config volume.
Use these to mount:
- Secrets (git auth tokens, OAuth client secrets, CA certs)
- ConfigMaps (registry data files)
- PersistentVolumeClaims (registry data on persistent storage)
- Any other volume type the registry server needs | | Optional: \{\}
| +| `volumeMounts` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io) array_ | VolumeMounts defines additional volume mounts for the registry-api container.
Each entry is a standard Kubernetes VolumeMount object (JSON/YAML).
The operator appends them to the container's volume mounts alongside the config mount.
Mount paths must match the file paths referenced in configYAML.
For example, if configYAML references passwordFile: /secrets/git-creds/token,
a corresponding volume mount must exist with mountPath: /secrets/git-creds. | | Optional: \{\}
| +| `pgpassSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#secretkeyselector-v1-core)_ | PGPassSecretRef references a Secret containing a pre-created pgpass file.
Why this is a dedicated field instead of a regular volume/volumeMount:
PostgreSQL's libpq rejects pgpass files that aren't mode 0600. Kubernetes
secret volumes mount files as root-owned, and the registry-api container
runs as non-root (UID 65532). A root-owned 0600 file is unreadable by
UID 65532, and using fsGroup changes permissions to 0640 which libpq also
rejects. The only solution is an init container that copies the file to an
emptyDir as the app user and runs chmod 0600. This cannot be expressed
through volumes/volumeMounts alone -- it requires an init container, two
extra volumes (secret + emptyDir), a subPath mount, and an environment
variable, all wired together correctly.
When specified, the operator generates all of that plumbing invisibly.
The user creates the Secret with pgpass-formatted content; the operator
handles only the Kubernetes permission mechanics.
Example Secret:
apiVersion: v1
kind: Secret
metadata:
name: my-pgpass
stringData:
.pgpass: \|
postgres:5432:registry:db_app:mypassword
postgres:5432:registry:db_migrator:otherpassword
Then reference it:
pgpassSecretRef:
name: my-pgpass
key: .pgpass | | Optional: \{\}
| +| `displayName` _string_ | DisplayName is a human-readable name for the registry. | | Optional: \{\}
| | `enforceServers` _boolean_ | EnforceServers indicates whether MCPServers in this namespace must have their images
present in at least one registry in the namespace. When any registry in the namespace
has this field set to true, enforcement is enabled for the entire namespace.
MCPServers with images not found in any registry will be rejected.
When false (default), MCPServers can be deployed regardless of registry presence. | false | Optional: \{\}
| -| `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the registry API server.
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the registry API server runs in, you must specify
the `registry-api` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML.
Works with both the new configYAML path and the legacy typed path. | | Type: object
Optional: \{\}
| -| `sources` _[api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) array_ | Sources defines the data source configurations for the registry.
Each source defines where registry data comes from (Git, API, ConfigMap, URL, Managed, or Kubernetes).
Deprecated: Use configYAML with volumes/volumeMounts instead. | | MaxItems: 20
Optional: \{\}
| -| `registries` _[api.v1alpha1.MCPRegistryViewConfig](#apiv1alpha1mcpregistryviewconfig) array_ | Registries defines lightweight registry views that aggregate one or more sources.
Each registry references sources by name and can optionally gate access via claims.
Deprecated: Use configYAML with volumes/volumeMounts instead. | | MaxItems: 20
Optional: \{\}
| -| `databaseConfig` _[api.v1alpha1.MCPRegistryDatabaseConfig](#apiv1alpha1mcpregistrydatabaseconfig)_ | DatabaseConfig defines the PostgreSQL database configuration for the registry API server.
If not specified, defaults will be used:
- Host: "postgres"
- Port: 5432
- User: "db_app"
- MigrationUser: "db_migrator"
- Database: "registry"
- SSLMode: "prefer"
- MaxOpenConns: 10
- MaxIdleConns: 2
- ConnMaxLifetime: "30m"
Deprecated: Put database config in configYAML and use pgpassSecretRef. | | Optional: \{\}
| -| `authConfig` _[api.v1alpha1.MCPRegistryAuthConfig](#apiv1alpha1mcpregistryauthconfig)_ | AuthConfig defines the authentication configuration for the registry API server.
If not specified, defaults to anonymous authentication.
Deprecated: Put auth config in configYAML instead. | | Optional: \{\}
| -| `telemetryConfig` _[api.v1alpha1.MCPRegistryTelemetryConfig](#apiv1alpha1mcpregistrytelemetryconfig)_ | TelemetryConfig defines OpenTelemetry configuration for the registry API server.
When enabled, the server exports traces and metrics via OTLP.
Deprecated: Put telemetry config in configYAML instead. | | Optional: \{\}
| +| `podTemplateSpec` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#rawextension-runtime-pkg)_ | PodTemplateSpec defines the pod template to use for the registry API server.
This allows for customizing the pod configuration beyond what is provided by the other fields.
Note that to modify the specific container the registry API server runs in, you must specify
the `registry-api` container name in the PodTemplateSpec.
This field accepts a PodTemplateSpec object as JSON/YAML. | | Type: object
Optional: \{\}
| #### api.v1alpha1.MCPRegistryStatus @@ -2123,63 +1820,6 @@ _Appears in:_ | `readyReplicas` _integer_ | ReadyReplicas is the number of ready registry API replicas | | Optional: \{\}
| -#### api.v1alpha1.MCPRegistryTelemetryConfig - - - -MCPRegistryTelemetryConfig defines OpenTelemetry configuration for the registry API server. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySpec](#apiv1alpha1mcpregistryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `enabled` _boolean_ | Enabled controls whether telemetry is enabled globally.
When false, no telemetry providers are initialized. | false | Optional: \{\}
| -| `serviceName` _string_ | ServiceName is the name of the service for telemetry identification.
Defaults to "thv-registry-api" if not specified. | | Optional: \{\}
| -| `serviceVersion` _string_ | ServiceVersion is the version of the service for telemetry identification. | | Optional: \{\}
| -| `endpoint` _string_ | Endpoint is the OTLP collector endpoint (host:port).
Defaults to "localhost:4318" if not specified. | | Optional: \{\}
| -| `insecure` _boolean_ | Insecure allows HTTP connections instead of HTTPS to the OTLP endpoint.
Should only be true for development/testing environments. | false | Optional: \{\}
| -| `tracing` _[api.v1alpha1.MCPRegistryTracingConfig](#apiv1alpha1mcpregistrytracingconfig)_ | Tracing defines tracing-specific configuration. | | Optional: \{\}
| -| `metrics` _[api.v1alpha1.MCPRegistryMetricsConfig](#apiv1alpha1mcpregistrymetricsconfig)_ | Metrics defines metrics-specific configuration. | | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryTracingConfig - - - -MCPRegistryTracingConfig defines tracing-specific configuration. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistryTelemetryConfig](#apiv1alpha1mcpregistrytelemetryconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `enabled` _boolean_ | Enabled controls whether tracing is enabled. | false | Optional: \{\}
| -| `sampling` _string_ | Sampling controls the trace sampling rate (0.0 to 1.0, exclusive of 0.0).
1.0 means sample all traces, 0.5 means sample 50%.
Defaults to 0.05 (5%) if not specified. | | Optional: \{\}
| - - -#### api.v1alpha1.MCPRegistryViewConfig - - - -MCPRegistryViewConfig defines a lightweight registry view that aggregates one or more sources. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySpec](#apiv1alpha1mcpregistryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is a unique identifier for this registry view | | MinLength: 1
Required: \{\}
| -| `sources` _string array_ | Sources is an ordered list of source names that feed this registry.
Each name must reference a source defined in spec.sources. | | MinItems: 1
Required: \{\}
| -| `claims` _[JSON](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#json-v1-apiextensions-k8s-io)_ | Claims are key-value pairs that gate access to this registry view.
Only requests with matching claims can access this registry. Values must be string or []string. | | Type: object
Optional: \{\}
| - - #### api.v1alpha1.MCPRemoteProxy @@ -2756,20 +2396,6 @@ _Appears in:_ | `referencingWorkloads` _[api.v1alpha1.WorkloadReference](#apiv1alpha1workloadreference) array_ | ReferencingWorkloads is a list of workload resources that reference this MCPToolConfig.
Each entry identifies the workload by kind and name. | | Optional: \{\}
| -#### api.v1alpha1.ManagedSource - - - -ManagedSource defines a managed source that is directly manipulated via the registry API. -Managed sources do not sync from external sources. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - - - #### api.v1alpha1.ModelCacheConfig @@ -2789,23 +2415,6 @@ _Appears in:_ | `accessMode` _string_ | AccessMode is the access mode for the PVC | ReadWriteOnce | Enum: [ReadWriteOnce ReadWriteMany ReadOnlyMany]
Optional: \{\}
| -#### api.v1alpha1.NameFilter - - - -NameFilter defines name-based filtering - - - -_Appears in:_ -- [api.v1alpha1.RegistryFilter](#apiv1alpha1registryfilter) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `include` _string array_ | Include is a list of glob patterns to include | | Optional: \{\}
| -| `exclude` _string array_ | Exclude is a list of glob patterns to exclude | | Optional: \{\}
| - - #### api.v1alpha1.NetworkPermissions @@ -3155,23 +2764,6 @@ _Appears in:_ | `caCertSecretRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | CACertSecretRef references a Secret containing a PEM-encoded CA certificate
for verifying the server. When not specified, system root CAs are used. | | Optional: \{\}
| -#### api.v1alpha1.RegistryFilter - - - -RegistryFilter defines include/exclude patterns for registry content - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `names` _[api.v1alpha1.NameFilter](#apiv1alpha1namefilter)_ | NameFilters defines name-based filtering | | Optional: \{\}
| -| `tags` _[api.v1alpha1.TagFilter](#apiv1alpha1tagfilter)_ | Tags defines tag-based filtering | | Optional: \{\}
| - - #### api.v1alpha1.ResourceList @@ -3375,42 +2967,6 @@ _Appears in:_ | `passwordRef` _[api.v1alpha1.SecretKeyRef](#apiv1alpha1secretkeyref)_ | PasswordRef is a reference to a Secret key containing the Redis password | | Optional: \{\}
| -#### api.v1alpha1.SyncPolicy - - - -SyncPolicy defines automatic synchronization behavior. -When specified, enables automatic synchronization at the given interval. -Manual synchronization via annotation-based triggers is always available -regardless of this policy setting. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `interval` _string_ | Interval is the sync interval for automatic synchronization (Go duration format)
Examples: "1h", "30m", "24h" | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Required: \{\}
| - - -#### api.v1alpha1.TagFilter - - - -TagFilter defines tag-based filtering - - - -_Appears in:_ -- [api.v1alpha1.RegistryFilter](#apiv1alpha1registryfilter) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `include` _string array_ | Include is a list of tags to include | | Optional: \{\}
| -| `exclude` _string array_ | Exclude is a list of tags to exclude | | Optional: \{\}
| - - #### api.v1alpha1.TelemetryConfig @@ -3571,24 +3127,6 @@ _Appears in:_ | `shared` _[api.v1alpha1.RateLimitBucket](#apiv1alpha1ratelimitbucket)_ | Shared defines a token bucket shared across all users for this specific tool. | | Required: \{\}
| -#### api.v1alpha1.URLSource - - - -URLSource defines a URL-hosted file source configuration. -The registry server fetches registry data from the specified HTTP/HTTPS URL. - - - -_Appears in:_ -- [api.v1alpha1.MCPRegistrySourceConfig](#apiv1alpha1mcpregistrysourceconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `endpoint` _string_ | Endpoint is the HTTP/HTTPS URL to fetch the registry file from.
HTTPS is required unless the host is localhost. | | MinLength: 1
Pattern: `^https?://.*`
Required: \{\}
| -| `timeout` _string_ | Timeout is the timeout for HTTP requests (Go duration format).
Defaults to "30s" if not specified. | | Pattern: `^([0-9]+(\.[0-9]+)?(ns\|us\|µs\|ms\|s\|m\|h))+$`
Optional: \{\}
| - - #### api.v1alpha1.UpstreamInjectSpec diff --git a/examples/operator/mcp-registries/mcpregistry-advanced-filtering.yaml b/examples/operator/mcp-registries/mcpregistry-advanced-filtering.yaml deleted file mode 100644 index 0506865f46..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-advanced-filtering.yaml +++ /dev/null @@ -1,294 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: advanced-filtering-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12:00:00Z", - "servers": { - "prod-postgres": { - "description": "Production PostgreSQL database server", - "tier": "Official", - "status": "Active", - "transport": "sse", - "tools": [ - "execute_sql", - "backup_database", - "analyze_performance" - ], - "metadata": { - "stars": 2500, - "pulls": 45000, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/postgres/prod-mcp-server", - "tags": [ - "database", - "postgresql", - "production", - "sql" - ], - "image": "postgres/prod-mcp:3.0.0", - "target_port": 8000, - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - }, - "dev-postgres": { - "description": "Development PostgreSQL database server with debugging features", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "execute_sql", - "debug_queries", - "mock_data" - ], - "metadata": { - "stars": 800, - "pulls": 5000, - "last_updated": "2025-09-05T12:00:00Z" - }, - "repository_url": "https://github.com/postgres/dev-mcp-server", - "tags": [ - "database", - "postgresql", - "development", - "debugging" - ], - "image": "postgres/dev-mcp:2.5.0", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "test-mongodb": { - "description": "MongoDB server for testing and integration tests", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "insert_document", - "query_collection", - "create_index" - ], - "metadata": { - "stars": 400, - "pulls": 2000, - "last_updated": "2025-09-03T12:00:00Z" - }, - "repository_url": "https://github.com/mongodb/test-mcp-server", - "tags": [ - "database", - "mongodb", - "testing", - "nosql" - ], - "image": "mongodb/test-mcp:1.8.0", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "experimental-graphql": { - "description": "Experimental GraphQL API server with advanced features", - "tier": "Community", - "status": "Active", - "transport": "streamable-http", - "tools": [ - "execute_query", - "introspect_schema", - "validate_mutation" - ], - "metadata": { - "stars": 150, - "pulls": 300, - "last_updated": "2025-08-20T12:00:00Z" - }, - "repository_url": "https://github.com/graphql/experimental-mcp", - "tags": [ - "api", - "graphql", - "experimental", - "web" - ], - "image": "graphql/experimental-mcp:0.9.0-alpha", - "target_port": 4000, - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - }, - "legacy-soap-api": { - "description": "Legacy SOAP API server - scheduled for deprecation", - "tier": "Community", - "status": "Active", - "transport": "streamable-http", - "tools": [ - "call_soap_service", - "parse_wsdl" - ], - "metadata": { - "stars": 20, - "pulls": 50, - "last_updated": "2024-03-01T12:00:00Z" - }, - "repository_url": "https://github.com/legacy/soap-mcp-server", - "tags": [ - "api", - "soap", - "legacy", - "deprecated" - ], - "image": "legacy/soap-mcp:1.0.0", - "target_port": 8080, - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - }, - "monitoring-metrics": { - "description": "Application monitoring and metrics collection server", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "collect_metrics", - "create_alert", - "generate_dashboard" - ], - "metadata": { - "stars": 1200, - "pulls": 8000, - "last_updated": "2025-09-07T12:00:00Z" - }, - "repository_url": "https://github.com/monitoring/metrics-mcp-server", - "tags": [ - "monitoring", - "metrics", - "observability", - "production" - ], - "image": "monitoring/metrics-mcp:2.3.0", - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - } - } - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: database-only-registry - namespace: toolhive-system -spec: - displayName: "Database Servers Only" - sources: - - name: default - format: toolhive - configMapRef: - name: advanced-filtering-registry-data - key: registry.json - syncPolicy: - interval: "45m" - filter: - names: - include: - - "*-postgres" # Only PostgreSQL servers - - "*-mongodb" # Only MongoDB servers - exclude: - - "legacy-*" # Exclude legacy servers - tags: - include: - - "database" # Only database servers - exclude: - - "deprecated" # Exclude deprecated servers - registries: - - name: default - sources: - - default ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: production-monitoring-registry - namespace: toolhive-system -spec: - displayName: "Production & Monitoring Tools" - sources: - - name: default - format: toolhive - configMapRef: - name: advanced-filtering-registry-data - key: registry.json - syncPolicy: - interval: "15m" # More frequent sync for production tools - filter: - names: - include: - - "prod-*" # Production servers - - "monitoring-*" # Monitoring servers - tags: - include: - - "production" # Production-ready servers - - "monitoring" # Monitoring tools - - "observability" # Observability tools - exclude: - - "experimental" # No experimental features - - "testing" # No testing tools - registries: - - name: default - sources: - - default ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: development-tools-registry - namespace: toolhive-system -spec: - displayName: "Development & Testing Tools" - sources: - - name: default - format: toolhive - configMapRef: - name: advanced-filtering-registry-data - key: registry.json - filter: - names: - exclude: - - "prod-*" # Exclude production servers - - "legacy-*" # Exclude legacy servers - tags: - include: - - "development" # Development tools - - "testing" # Testing tools - - "debugging" # Debugging tools - exclude: - - "production" # No production servers - - "deprecated" # No deprecated tools - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-api-advanced.yaml b/examples/operator/mcp-registries/mcpregistry-api-advanced.yaml deleted file mode 100644 index f9d7c0421c..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-api-advanced.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: filtered-toolhive-api-registry - namespace: default -spec: - displayName: "Filtered ToolHive Registry via API" - sources: - - name: default - format: toolhive - api: - # Base API URL - controller will append /v0/servers - endpoint: "http://my-registry-api.default.svc.cluster.local" - syncPolicy: - # Sync every 30 minutes - interval: "30m" - # Apply filters to the fetched registry data - filter: - names: - # Only include these servers - include: ["filesystem", "github", "gitlab"] - tags: - # Only include servers with "official" tag - include: ["official"] - # Exclude experimental servers - exclude: ["experimental"] - registries: - - name: default - sources: - - default diff --git a/examples/operator/mcp-registries/mcpregistry-api-basic.yaml b/examples/operator/mcp-registries/mcpregistry-api-basic.yaml deleted file mode 100644 index 025f88329c..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-api-basic.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: toolhive-api-registry - namespace: default -spec: - displayName: "ToolHive Registry via API" - sources: - - name: default - format: toolhive - api: - # Base API URL - controller will append /v0/servers - endpoint: "http://my-registry-api.default.svc.cluster.local" - syncPolicy: - interval: "1h" - registries: - - name: default - sources: - - default diff --git a/examples/operator/mcp-registries/mcpregistry-api-external.yaml b/examples/operator/mcp-registries/mcpregistry-api-external.yaml deleted file mode 100644 index 3620666d44..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-api-external.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: external-toolhive-api-registry - namespace: default -spec: - displayName: "External ToolHive Registry via HTTPS" - sources: - - name: default - format: toolhive - api: - # External registry API endpoint via HTTPS - # Base URL - controller appends /v0/servers - endpoint: "https://registry.example.com" - syncPolicy: - interval: "2h" - registries: - - name: default - sources: - - default - # Note: This example shows connecting to an external registry over HTTPS - # Authentication support will be added in Phase 2 diff --git a/examples/operator/mcp-registries/mcpregistry-automatic-sync.yaml b/examples/operator/mcp-registries/mcpregistry-automatic-sync.yaml deleted file mode 100644 index 8f0a535675..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-automatic-sync.yaml +++ /dev/null @@ -1,253 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: auto-sync-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12:00:00Z", - "servers": { - "postgres-mcp-pro": { - "description": "Provides configurable read/write access and performance analysis for PostgreSQL databases.", - "tier": "Official", - "status": "Active", - "transport": "sse", - "tools": [ - "list_schemas", - "execute_sql", - "explain_query" - ], - "metadata": { - "stars": 1043, - "pulls": 22175, - "last_updated": "2025-09-01T02:37:23Z" - }, - "repository_url": "https://github.com/crystaldba/postgres-mcp", - "tags": [ - "database", - "postgresql", - "sql", - "analytics", - "production" - ], - "image": "crystaldba/postgres-mcp:0.3.0", - "target_port": 8000, - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - }, - "env_vars": [ - { - "name": "DATABASE_URI", - "description": "PostgreSQL connection string, like 'postgresql://username:password@host.docker.internal:5432/dbname'", - "required": true, - "secret": true - } - ], - "args": [ - "--transport=sse", - "--sse-host=0.0.0.0", - "--sse-port=8000" - ] - }, - "mysql-connector": { - "description": "MySQL database connector with advanced query capabilities", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "execute_query", - "show_tables", - "describe_table", - "backup_database" - ], - "metadata": { - "stars": 890, - "pulls": 15200, - "last_updated": "2025-09-05T08:15:00Z" - }, - "repository_url": "https://github.com/mysql/mysql-mcp-server", - "tags": [ - "database", - "mysql", - "sql", - "production" - ], - "image": "mysql/mcp-server:2.1.0", - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - }, - "env_vars": [ - { - "name": "MYSQL_CONNECTION_STRING", - "description": "MySQL connection string", - "required": true, - "secret": true - } - ] - }, - "redis-cache": { - "description": "Redis caching and key-value operations", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "get_key", - "set_key", - "delete_key", - "list_keys" - ], - "metadata": { - "stars": 650, - "pulls": 8900, - "last_updated": "2025-09-03T14:22:00Z" - }, - "repository_url": "https://github.com/redis/redis-mcp-server", - "tags": [ - "database", - "redis", - "cache", - "production" - ], - "image": "redis/mcp-server:1.5.0", - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - }, - "fetch-web": { - "description": "Allows you to fetch content from the web", - "tier": "Community", - "status": "Active", - "transport": "streamable-http", - "tools": [ - "fetch" - ], - "metadata": { - "stars": 17, - "pulls": 12390, - "last_updated": "2025-09-05T02:29:06Z" - }, - "repository_url": "https://github.com/stackloklabs/gofetch", - "tags": [ - "web", - "scraping", - "fetch", - "http", - "development" - ], - "image": "ghcr.io/stackloklabs/gofetch/server:0.0.6", - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true, - "allow_port": [ - 443 - ] - } - } - } - }, - "analytics-beta": { - "description": "Beta analytics and reporting server with experimental features", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "generate_report", - "analyze_data", - "export_metrics" - ], - "metadata": { - "stars": 85, - "pulls": 200, - "last_updated": "2025-09-01T09:30:00Z" - }, - "repository_url": "https://github.com/analytics/beta-mcp-server", - "tags": [ - "analytics", - "reporting", - "beta", - "experimental" - ], - "image": "analytics/beta-server:0.8.0-beta", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "old-api-gateway": { - "description": "Legacy API gateway - use new microservices architecture instead", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "route_request", - "manage_endpoints" - ], - "metadata": { - "stars": 45, - "pulls": 150, - "last_updated": "2024-12-01T10:00:00Z" - }, - "repository_url": "https://github.com/legacy/api-gateway-mcp", - "tags": [ - "api", - "gateway", - "legacy", - "deprecated" - ], - "image": "legacy/api-gateway:v1.2", - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - } - } - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: auto-sync-registry - namespace: toolhive-system -spec: - displayName: "Auto-Sync MCP Server Registry" - sources: - - name: default - format: toolhive - configMapRef: - name: auto-sync-registry-data - key: registry.json - syncPolicy: - interval: "30m" # Automatically sync every 30 minutes - filter: - tags: - include: - - "database" # Only database servers - - "production" # Only production-ready servers - exclude: - - "experimental" # Exclude experimental servers - - "deprecated" # Exclude deprecated servers - - "beta" # Exclude beta versions - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-configmap.yaml b/examples/operator/mcp-registries/mcpregistry-configmap.yaml deleted file mode 100644 index 426e7ad067..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-configmap.yaml +++ /dev/null @@ -1,248 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: example-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12: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-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "development" - ], - "image": "docker.io/mcp/filesystem:dev", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "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-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "storage", - "production" - ], - "image": "docker.io/mcp/filesystem:latest", - "permissions": { - "network": { - "outbound": {} - } - }, - "args": [ - "/projects" - ] - }, - "github-api": { - "description": "Provides integration with GitHub's APIs", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "create_issue", - "create_pull_request", - "search_repositories" - ], - "metadata": { - "stars": 2200, - "pulls": 800, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/github/github-mcp-server", - "tags": [ - "github", - "api", - "repository", - "production" - ], - "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 - } - ] - }, - "slack-notifications": { - "description": "Send notifications and messages to Slack channels", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "send_message", - "create_channel", - "list_channels" - ], - "metadata": { - "stars": 450, - "pulls": 120, - "last_updated": "2025-09-05T12:00:00Z" - }, - "repository_url": "https://github.com/slack/slack-mcp-server", - "tags": [ - "slack", - "notifications", - "messaging", - "development" - ], - "image": "slack/mcp-server:latest", - "permissions": { - "network": { - "outbound": { - "allow_host": [ - ".slack.com" - ], - "allow_port": [ - 443 - ] - } - } - } - }, - "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-09-03T12:00:00Z" - }, - "repository_url": "https://github.com/testing/test-runner-mcp", - "tags": [ - "testing", - "quality", - "automation", - "development" - ], - "image": "testing/test-runner:latest", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "legacy-xml-parser": { - "description": "Legacy XML parsing server - deprecated in favor of modern alternatives", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "parse_xml", - "validate_schema" - ], - "metadata": { - "stars": 25, - "pulls": 10, - "last_updated": "2024-06-01T12:00:00Z" - }, - "repository_url": "https://github.com/legacy/xml-parser-mcp", - "tags": [ - "xml", - "parsing", - "legacy", - "deprecated" - ], - "image": "legacy/xml-parser:v1", - "permissions": { - "network": { - "outbound": {} - } - } - } - } - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: example-registry - namespace: toolhive-system -spec: - displayName: "Example MCP Server Registry" - sources: - - name: default - format: toolhive - configMapRef: - name: example-registry-data - key: registry.json - syncPolicy: - interval: "1h" - filter: - names: - include: - - "prod-*" # Only production servers - - "github-*" # GitHub integration servers - exclude: - - "*-legacy" # Exclude legacy versions - tags: - include: - - "production" # Only production-ready servers - - "api" # API integration servers - exclude: - - "deprecated" # Exclude deprecated servers - - "legacy" # Exclude legacy servers - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-enforcing.yaml b/examples/operator/mcp-registries/mcpregistry-enforcing.yaml deleted file mode 100644 index c6cefcd74c..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-enforcing.yaml +++ /dev/null @@ -1,162 +0,0 @@ -# Example MCPRegistry with enforcement enabled -# This demonstrates how to create a registry that enforces image validation -# When enforceServers: true, MCPServers in this namespace must exist in the registry -apiVersion: v1 -kind: ConfigMap -metadata: - name: enforcing-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12:00:00Z", - "servers": { - "filesystem": { - "description": "Allows you to do filesystem operations", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "read_file", - "write_file", - "list_directory", - "create_directory" - ], - "metadata": { - "stars": 1500, - "pulls": 500, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "approved" - ], - "image": "docker.io/mcp/filesystem:latest", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "github": { - "description": "Provides integration with GitHub's APIs", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "create_issue", - "create_pull_request", - "search_repositories" - ], - "metadata": { - "stars": 2200, - "pulls": 800, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/github/github-mcp-server", - "tags": [ - "github", - "approved" - ], - "image": "ghcr.io/github/github-mcp-server:latest", - "permissions": { - "network": { - "outbound": { - "allow_host": [ - ".github.com", - ".githubusercontent.com" - ], - "allow_port": [ - 443 - ] - } - } - } - }, - "anthropic-tools": { - "description": "Anthropic's official MCP tools", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "analyze", - "generate", - "transform" - ], - "metadata": { - "stars": 5000, - "pulls": 2000, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/anthropics/mcp-tools", - "tags": [ - "anthropic", - "approved", - "official" - ], - "image": "docker.io/anthropic/mcp-tools:v1.0.0", - "permissions": { - "network": { - "outbound": {} - } - } - } - }, - "groups": [ - { - "name": "development-tools", - "description": "Development and coding tools", - "servers": { - "code-analyzer": { - "description": "Static code analysis tools", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "analyze_python", - "analyze_go", - "analyze_javascript" - ], - "tags": [ - "development", - "approved" - ], - "image": "docker.io/mcp/code-analyzer:v2.1.0", - "permissions": { - "network": { - "outbound": {} - } - } - } - } - } - ] - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: enforcing-registry - namespace: toolhive-system -spec: - displayName: "Approved MCP Servers (Enforcing)" - # When enforceServers is true, MCPServers in this namespace must exist in this registry - # Images allowed by this registry: - # - docker.io/mcp/filesystem:latest - # - ghcr.io/github/github-mcp-server:latest - # - docker.io/anthropic/mcp-tools:v1.0.0 - # - docker.io/mcp/code-analyzer:v2.1.0 (from the development-tools group) - enforceServers: true - sources: - - name: default - format: toolhive - configMapRef: - name: enforcing-registry-data - key: registry.json - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-git-commit.yaml b/examples/operator/mcp-registries/mcpregistry-git-commit.yaml deleted file mode 100644 index 9df11e5f39..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-git-commit.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: toolhive-git-commit - namespace: toolhive-system -spec: - displayName: "ToolHive Registry from Specific Commit" - sources: - - name: default - format: toolhive - git: - repository: "https://github.com/stacklok/toolhive" - # Using a specific commit instead of branch for reproducible deployments - commit: "395b6ba11bdd60b615a9630a66dcede2abcfbb48" # A valid full commit hash - path: "pkg/registry/data/registry.json" - syncPolicy: - interval: "24h" # Sync less frequently for pinned commits - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-git-private.yaml b/examples/operator/mcp-registries/mcpregistry-git-private.yaml deleted file mode 100644 index 5ddec29c51..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-git-private.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# Example: MCPRegistry with private Git repository authentication -# -# This example demonstrates how to configure an MCPRegistry to sync from -# a private Git repository using HTTP Basic authentication. -# -# Prerequisites: -# 1. Create a Personal Access Token (PAT) with read access to the repository -# - GitHub: Create a PAT at https://github.com/settings/tokens with `repo` scope -# - GitLab: Create a token at Settings > Access Tokens with `read_repository` scope -# 2. Create the Secret (see below) -# 3. Apply this MCPRegistry resource - ---- -# Secret containing the Git credentials -# IMPORTANT: Use stringData for plain text or data for base64-encoded values -apiVersion: v1 -kind: Secret -metadata: - name: private-registry-credentials - namespace: toolhive-system -type: Opaque -stringData: - # For GitHub PATs, use "ghp_..." token - # For GitLab, use the personal access token - # For Bitbucket, use an app password - password: "ghp_your_personal_access_token_here" - ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: private-git-registry - namespace: toolhive-system -spec: - displayName: "Private Git Registry" - sources: - - name: default - format: toolhive - git: - # HTTPS URL to your private repository - repository: "https://github.com/your-org/private-mcp-registry" - branch: "main" - path: "registry.json" - # Authentication configuration - auth: - # Username depends on Git provider: - # - GitHub PAT: use "git" - # - GitLab token: use "oauth2" - # - Bitbucket app password: use your Bitbucket username - username: "git" - # Reference to the Secret containing the password/token - passwordSecretRef: - name: private-registry-credentials - key: password - syncPolicy: - interval: "1h" - registries: - - name: default - sources: - - default diff --git a/examples/operator/mcp-registries/mcpregistry-git-simple.yaml b/examples/operator/mcp-registries/mcpregistry-git-simple.yaml deleted file mode 100644 index f3cbf3d8cc..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-git-simple.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: toolhive-git-simple - namespace: toolhive-system -spec: - displayName: "ToolHive Registry from Git" - sources: - - name: default - format: toolhive - git: - repository: "https://github.com/stacklok/toolhive" - branch: "main" - path: "pkg/registry/data/registry.json" - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-git-toolhive.yaml b/examples/operator/mcp-registries/mcpregistry-git-toolhive.yaml deleted file mode 100644 index 1d1c1c297a..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-git-toolhive.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: toolhive-git-registry - namespace: toolhive-system -spec: - displayName: "ToolHive Official Registry (Git)" - sources: - - name: default - format: toolhive - git: - repository: "https://github.com/stacklok/toolhive" - branch: "main" - path: "pkg/registry/data/registry.json" - syncPolicy: - interval: "1h" - filter: - tags: - include: - - "database" - - "filesystem" - - "api" - exclude: - - "experimental" - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-minimal.yaml b/examples/operator/mcp-registries/mcpregistry-minimal.yaml deleted file mode 100644 index dd8f480723..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-minimal.yaml +++ /dev/null @@ -1,163 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: minimal-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12:00:00Z", - "servers": { - "filesystem": { - "description": "Allows you to do filesystem operations. Mount paths under /projects using --volume.", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "read_file", - "write_file", - "list_directory", - "create_directory" - ], - "metadata": { - "stars": 1500, - "pulls": 500, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "storage", - "production" - ], - "image": "docker.io/mcp/filesystem:latest", - "permissions": { - "network": { - "outbound": {} - } - }, - "args": [ - "/projects" - ] - }, - "github": { - "description": "Provides integration with GitHub's APIs", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "create_issue", - "create_pull_request", - "search_repositories" - ], - "metadata": { - "stars": 2200, - "pulls": 800, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/github/github-mcp-server", - "tags": [ - "github", - "api", - "repository", - "production" - ], - "image": "ghcr.io/github/github-mcp-server:latest", - "permissions": { - "network": { - "outbound": { - "allow_host": [ - ".github.com", - ".githubusercontent.com" - ], - "allow_port": [ - 443 - ] - } - } - } - }, - "experimental-ai": { - "description": "Experimental AI assistant with advanced features", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "analyze_code", - "generate_docs", - "suggest_improvements" - ], - "metadata": { - "stars": 150, - "pulls": 50, - "last_updated": "2025-09-01T12:00:00Z" - }, - "repository_url": "https://github.com/example/experimental-ai", - "tags": [ - "ai", - "experimental", - "analysis" - ], - "image": "docker.io/mcp/experimental-ai:latest", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "deprecated-tool": { - "description": "Legacy tool server - use new alternatives", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "legacy_process" - ], - "metadata": { - "stars": 10, - "pulls": 5, - "last_updated": "2024-01-01T12:00:00Z" - }, - "repository_url": "https://github.com/example/deprecated-tool", - "tags": [ - "legacy", - "deprecated", - "tools" - ], - "image": "docker.io/mcp/deprecated-tool:v1", - "permissions": { - "network": { - "outbound": {} - } - } - } - } - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: minimal-registry - namespace: toolhive-system -spec: - displayName: "Production-Ready MCP Servers" - sources: - - name: default - format: toolhive - configMapRef: - name: minimal-registry-data - key: registry.json - filter: - tags: - include: - - "production" - exclude: - - "experimental" - - "deprecated" - registries: - - name: default - sources: - - default \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-multiple-configmap.yaml b/examples/operator/mcp-registries/mcpregistry-multiple-configmap.yaml deleted file mode 100644 index a80bb0bdd8..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-multiple-configmap.yaml +++ /dev/null @@ -1,485 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: example-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12: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-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "development" - ], - "image": "docker.io/mcp/filesystem:dev", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "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-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "storage", - "production" - ], - "image": "docker.io/mcp/filesystem:latest", - "permissions": { - "network": { - "outbound": {} - } - }, - "args": [ - "/projects" - ] - }, - "github-api": { - "description": "Provides integration with GitHub's APIs", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "create_issue", - "create_pull_request", - "search_repositories" - ], - "metadata": { - "stars": 2200, - "pulls": 800, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/github/github-mcp-server", - "tags": [ - "github", - "api", - "repository", - "production" - ], - "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 - } - ] - }, - "slack-notifications": { - "description": "Send notifications and messages to Slack channels", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "send_message", - "create_channel", - "list_channels" - ], - "metadata": { - "stars": 450, - "pulls": 120, - "last_updated": "2025-09-05T12:00:00Z" - }, - "repository_url": "https://github.com/slack/slack-mcp-server", - "tags": [ - "slack", - "notifications", - "messaging", - "development" - ], - "image": "slack/mcp-server:latest", - "permissions": { - "network": { - "outbound": { - "allow_host": [ - ".slack.com" - ], - "allow_port": [ - 443 - ] - } - } - } - }, - "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-09-03T12:00:00Z" - }, - "repository_url": "https://github.com/testing/test-runner-mcp", - "tags": [ - "testing", - "quality", - "automation", - "development" - ], - "image": "testing/test-runner:latest", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "legacy-xml-parser": { - "description": "Legacy XML parsing server - deprecated in favor of modern alternatives", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "parse_xml", - "validate_schema" - ], - "metadata": { - "stars": 25, - "pulls": 10, - "last_updated": "2024-06-01T12:00:00Z" - }, - "repository_url": "https://github.com/legacy/xml-parser-mcp", - "tags": [ - "xml", - "parsing", - "legacy", - "deprecated" - ], - "image": "legacy/xml-parser:v1", - "permissions": { - "network": { - "outbound": {} - } - } - } - } - } ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: example-registry-data-new - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12: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-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "development" - ], - "image": "docker.io/mcp/filesystem:dev", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "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-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/modelcontextprotocol/servers", - "tags": [ - "filesystem", - "files", - "storage", - "production" - ], - "image": "docker.io/mcp/filesystem:latest", - "permissions": { - "network": { - "outbound": {} - } - }, - "args": [ - "/projects" - ] - }, - "github-api": { - "description": "Provides integration with GitHub's APIs", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "create_issue", - "create_pull_request", - "search_repositories" - ], - "metadata": { - "stars": 2200, - "pulls": 800, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/github/github-mcp-server", - "tags": [ - "github", - "api", - "repository", - "production" - ], - "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 - } - ] - }, - "slack-notifications": { - "description": "Send notifications and messages to Slack channels", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "send_message", - "create_channel", - "list_channels" - ], - "metadata": { - "stars": 450, - "pulls": 120, - "last_updated": "2025-09-05T12:00:00Z" - }, - "repository_url": "https://github.com/slack/slack-mcp-server", - "tags": [ - "slack", - "notifications", - "messaging", - "development" - ], - "image": "slack/mcp-server:latest", - "permissions": { - "network": { - "outbound": { - "allow_host": [ - ".slack.com" - ], - "allow_port": [ - 443 - ] - } - } - } - }, - "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-09-03T12:00:00Z" - }, - "repository_url": "https://github.com/testing/test-runner-mcp", - "tags": [ - "testing", - "quality", - "automation", - "development" - ], - "image": "testing/test-runner:latest", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "legacy-xml-parser": { - "description": "Legacy XML parsing server - deprecated in favor of modern alternatives", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "parse_xml", - "validate_schema" - ], - "metadata": { - "stars": 25, - "pulls": 10, - "last_updated": "2024-06-01T12:00:00Z" - }, - "repository_url": "https://github.com/legacy/xml-parser-mcp", - "tags": [ - "xml", - "parsing", - "legacy", - "deprecated" - ], - "image": "legacy/xml-parser:v1", - "permissions": { - "network": { - "outbound": {} - } - } - } - } - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: example-registry - namespace: toolhive-system -spec: - displayName: "Example MCP Server Registry" - sources: - - name: default - format: toolhive - configMapRef: - name: example-registry-data - key: registry.json - syncPolicy: - interval: "1h" - filter: - names: - include: - - "prod-*" # Only production servers - - "github-*" # GitHub integration servers - exclude: - - "*-legacy" # Exclude legacy versions - tags: - include: - - "production" # Only production-ready servers - - "api" # API integration servers - exclude: - - "deprecated" # Exclude deprecated servers - - "legacy" # Exclude legacy servers - - name: default-new - format: toolhive - configMapRef: - name: example-registry-data-new - key: registry.json - syncPolicy: - interval: "1h" - filter: - names: - include: - - "prod-*" # Only production servers - - "github-*" # GitHub integration servers - exclude: - - "*-legacy" # Exclude legacy versions - tags: - include: - - "production" # Only production-ready servers - - "api" # API integration servers - exclude: - - "deprecated" # Exclude deprecated servers - - "legacy" # Exclude legacy servers - registries: - - name: default - sources: - - default - - default-new \ No newline at end of file diff --git a/examples/operator/mcp-registries/mcpregistry-tag-filtering.yaml b/examples/operator/mcp-registries/mcpregistry-tag-filtering.yaml deleted file mode 100644 index 5af8588f81..0000000000 --- a/examples/operator/mcp-registries/mcpregistry-tag-filtering.yaml +++ /dev/null @@ -1,297 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: tag-filtering-registry-data - namespace: toolhive-system -data: - registry.json: | - { - "$schema": "https://raw.githubusercontent.com/stacklok/toolhive/main/pkg/registry/data/toolhive-legacy-registry.schema.json", - "version": "1.0.0", - "last_updated": "2025-09-08T12:00:00Z", - "servers": { - "secure-vault": { - "description": "Secure secrets management and encryption server", - "tier": "Official", - "status": "Active", - "transport": "stdio", - "tools": [ - "store_secret", - "retrieve_secret", - "encrypt_data", - "decrypt_data" - ], - "metadata": { - "stars": 3200, - "pulls": 75000, - "last_updated": "2025-09-08T12:00:00Z" - }, - "repository_url": "https://github.com/vault/secure-mcp-server", - "tags": [ - "security", - "encryption", - "secrets", - "production", - "compliance" - ], - "image": "vault/secure-mcp:4.1.0", - "permissions": { - "network": { - "outbound": { - "allow_host": [ - ".vault.io" - ], - "allow_port": [ - 443 - ] - } - } - } - }, - "auth-service": { - "description": "Authentication and authorization service", - "tier": "Official", - "status": "Active", - "transport": "streamable-http", - "tools": [ - "authenticate_user", - "authorize_action", - "manage_permissions", - "create_token" - ], - "metadata": { - "stars": 2800, - "pulls": 60000, - "last_updated": "2025-09-07T12:00:00Z" - }, - "repository_url": "https://github.com/auth/service-mcp-server", - "tags": [ - "security", - "authentication", - "authorization", - "production", - "identity" - ], - "image": "auth/service-mcp:3.5.0", - "target_port": 9000, - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - }, - "compliance-scanner": { - "description": "Security compliance and vulnerability scanning", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "scan_vulnerabilities", - "check_compliance", - "generate_report", - "remediate_issue" - ], - "metadata": { - "stars": 1500, - "pulls": 15000, - "last_updated": "2025-09-06T12:00:00Z" - }, - "repository_url": "https://github.com/security/compliance-mcp-server", - "tags": [ - "security", - "compliance", - "scanning", - "vulnerability", - "production" - ], - "image": "security/compliance-mcp:2.8.0", - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - }, - "file-sync": { - "description": "File synchronization and backup service", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "sync_files", - "backup_directory", - "restore_backup", - "compare_files" - ], - "metadata": { - "stars": 800, - "pulls": 5000, - "last_updated": "2025-09-04T12:00:00Z" - }, - "repository_url": "https://github.com/files/sync-mcp-server", - "tags": [ - "files", - "backup", - "sync", - "storage", - "utility" - ], - "image": "files/sync-mcp:1.9.0", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "log-analyzer": { - "description": "Log analysis and pattern detection service", - "tier": "Community", - "status": "Active", - "transport": "stdio", - "tools": [ - "parse_logs", - "detect_patterns", - "create_alert", - "export_analysis" - ], - "metadata": { - "stars": 650, - "pulls": 3500, - "last_updated": "2025-09-03T12:00:00Z" - }, - "repository_url": "https://github.com/logs/analyzer-mcp-server", - "tags": [ - "logs", - "analysis", - "monitoring", - "patterns", - "utility" - ], - "image": "logs/analyzer-mcp:2.2.0", - "permissions": { - "network": { - "outbound": {} - } - } - }, - "ml-inference": { - "description": "Machine learning model inference server", - "tier": "Community", - "status": "Active", - "transport": "streamable-http", - "tools": [ - "run_inference", - "load_model", - "validate_input", - "get_predictions" - ], - "metadata": { - "stars": 1200, - "pulls": 8000, - "last_updated": "2025-09-05T12:00:00Z" - }, - "repository_url": "https://github.com/ml/inference-mcp-server", - "tags": [ - "machine-learning", - "ai", - "inference", - "models", - "experimental" - ], - "image": "ml/inference-mcp:1.4.0", - "target_port": 8888, - "permissions": { - "network": { - "outbound": { - "insecure_allow_all": true - } - } - } - } - } - } ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: security-tools-registry - namespace: toolhive-system -spec: - displayName: "Security & Compliance Tools" - sources: - - name: default - format: toolhive - configMapRef: - name: tag-filtering-registry-data - key: registry.json - syncPolicy: - interval: "20m" - filter: - tags: - include: - - "security" # Security-related tools - - "compliance" # Compliance tools - - "encryption" # Encryption services - exclude: - - "experimental" # No experimental security tools - registries: - - name: default - sources: - - default ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: utility-tools-registry - namespace: toolhive-system -spec: - displayName: "Utility & Support Tools" - sources: - - name: default - format: toolhive - configMapRef: - name: tag-filtering-registry-data - key: registry.json - filter: - tags: - include: - - "utility" # Utility tools - - "backup" # Backup services - - "monitoring" # Monitoring tools - - "analysis" # Analysis tools - exclude: - - "security" # Exclude security tools (handled by other registry) - - "experimental" # No experimental utilities - registries: - - name: default - sources: - - default ---- -apiVersion: toolhive.stacklok.dev/v1alpha1 -kind: MCPRegistry -metadata: - name: ai-ml-registry - namespace: toolhive-system -spec: - displayName: "AI & Machine Learning Tools" - sources: - - name: default - format: toolhive - configMapRef: - name: tag-filtering-registry-data - key: registry.json - filter: - tags: - include: - - "ai" # AI tools - - "machine-learning" # ML tools - - "inference" # Inference engines - - "models" # Model management - # Note: No exclude filters - include all AI/ML tools even if experimental - registries: - - name: default - sources: - - default \ No newline at end of file