diff --git a/.github/upstream-projects.yaml b/.github/upstream-projects.yaml index 03ac27b1..7182c2b6 100644 --- a/.github/upstream-projects.yaml +++ b/.github/upstream-projects.yaml @@ -44,7 +44,7 @@ projects: - id: toolhive repo: stacklok/toolhive - version: v0.44.0 + version: v0.45.0 # toolhive is a monorepo covering the CLI, the Kubernetes # operator, and the vMCP gateway. It also introduces cross- # cutting features that land in concepts/, integrations/, diff --git a/docs/toolhive/concepts/observability.mdx b/docs/toolhive/concepts/observability.mdx index 7d0e5237..b14d5625 100644 --- a/docs/toolhive/concepts/observability.mdx +++ b/docs/toolhive/concepts/observability.mdx @@ -231,8 +231,8 @@ For details, see the When [rate limiting](../guides-k8s/rate-limiting.mdx) is configured on an MCPServer or VirtualMCPServer, ToolHive emits metrics for bucket decisions, -Redis errors, and Redis Lua check latency so you can watch for rejections and -detect fail-open periods when Redis is unreachable. See +Redis errors, fail-open events, and Redis Lua check latency so you can watch for +rejections and detect periods when enforcement had to fail open. See [Observe rate limit activity](../guides-k8s/rate-limiting.mdx#observe-rate-limit-activity) for metric names, attributes, and example queries. @@ -273,6 +273,16 @@ ToolHive can expose Prometheus-style metrics at a `/metrics` endpoint, enabling: - **Service discovery** in Kubernetes environments - **Integration** with existing Prometheus-based monitoring stacks +The Prometheus endpoint is served on a dedicated diagnostics listener (port +`9464` by default) so scraper access can be governed by port with a firewall or +Kubernetes `NetworkPolicy`. For compatibility, `/metrics` is also served on the +MCP transport port; that duplicate can be disabled per workload with +`--otel-metrics-on-transport-port=false` on the CLI or +`spec.prometheus.metricsOnTransportPort: false` on the `MCPTelemetryConfig`, and +the default is expected to flip to off in a future release. The diagnostics +listener carries no authentication middleware by design; restrict access at the +network layer. + ### Dual export Both OTLP and Prometheus can be enabled simultaneously, allowing you to: diff --git a/docs/toolhive/guides-cli/api-server.mdx b/docs/toolhive/guides-cli/api-server.mdx index 67a8b029..334c6d85 100644 --- a/docs/toolhive/guides-cli/api-server.mdx +++ b/docs/toolhive/guides-cli/api-server.mdx @@ -26,6 +26,20 @@ instances. ::: +## Send JSON request bodies + +For `POST`, `PUT`, `PATCH`, and `DELETE` requests with a body, set +`Content-Type: application/json`. Otherwise, the API returns +`415 Unsupported Media Type`. + +For example, add the header when using `curl -d`: + +```bash +curl -X POST http://127.0.0.1:8080/api/v1beta/workloads \ + -H 'Content-Type: application/json' \ + -d '{"name":"fetch","image":"ghcr.io/example/fetch:latest"}' +``` + ## Start the API server To start the API server, use the following command: diff --git a/docs/toolhive/guides-cli/skills-management.mdx b/docs/toolhive/guides-cli/skills-management.mdx index cc452769..aea29c2b 100644 --- a/docs/toolhive/guides-cli/skills-management.mdx +++ b/docs/toolhive/guides-cli/skills-management.mdx @@ -244,6 +244,19 @@ Missing or drifted skills are reinstalled at their pinned digest. Sync prompts for confirmation before installing; pass `--yes` in non-interactive environments such as CI. +By default, `thv skill sync` targets **every** skill-supporting client installed +on the machine, so a lock entry is only current when the skill is present in +each client's directory. Pass `--clients` to constrain sync to a specific set: + +```bash +thv skill sync --project-root . --clients claude-code,cursor +``` + +This is also the safest option in CI, where an unexpected client (for example, a +newly supported IDE that appears in a `thv` upgrade) would otherwise be treated +as drift and materialize the skill into a directory the project doesn't check +in. + Use `--check` to report drift without installing or writing anything. This is useful as a CI gate: @@ -382,19 +395,46 @@ After building, push the artifact to a remote OCI registry: thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 ``` -`thv skill push` signs the pushed artifact by default. Pass `--key` to use a -cosign private key on disk, or `--no-sign` to push without signing: +`thv skill push` signs the pushed artifact. Pick one of three signing modes: + +- **Keyless (default when no flag is set)**: ToolHive acquires a short-lived + Sigstore identity token and records the signature in the public transparency + log. In GitHub Actions with `id-token: write` permission, the ambient OIDC + token is used automatically; on an interactive terminal, ToolHive prompts for + browser sign-in; in any other environment, the push fails with an actionable + error before publishing anything. +- **Key-pair with `--key`**: sign with a cosign private key on disk. Set + `COSIGN_PASSWORD` in the `thv serve` environment to decrypt an encrypted key. +- **Explicitly unsigned with `--no-sign`**: publish without any signature. + +The three signing inputs are mutually exclusive. Combining `--key`, +`--identity-token`, and `--no-sign` returns a `400` from the API. ```bash -# Sign with a cosign key on disk. Set COSIGN_PASSWORD in the thv serve -# environment to decrypt an encrypted key. +# Keyless (recommended in CI with id-token: write) +thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 + +# Sign with a cosign key on disk. thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 \ --key cosign.key -# Push without a signature +# Supply a pre-acquired OIDC identity token (advanced). +thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 \ + --identity-token "$IDENTITY_TOKEN" + +# Push without a signature. thv skill push ghcr.io/my-org/skills/my-skill:v1.0.0 --no-sign ``` +:::tip[In CI, pass a signing flag explicitly] + +A bare `thv skill push` in CI without `id-token: write` fails before the push +completes. Pass one of `--key`, `--identity-token`, or `--no-sign` explicitly, +or ensure the workflow grants `id-token: write` at the job level so keyless +signing succeeds. + +::: + Signatures let consumers verify who published a skill before installing it. When a consumer installs an unsigned skill project-scoped, they must pass `--allow-unsigned` to `thv skill install`. User-scoped installs do not enforce diff --git a/docs/toolhive/guides-cli/telemetry-and-metrics.mdx b/docs/toolhive/guides-cli/telemetry-and-metrics.mdx index 0028fb6c..1db06ff5 100644 --- a/docs/toolhive/guides-cli/telemetry-and-metrics.mdx +++ b/docs/toolhive/guides-cli/telemetry-and-metrics.mdx @@ -163,8 +163,19 @@ compatibility with observability tools. ### Enable Prometheus metrics -You can expose Prometheus-style metrics at `/metrics` on the main transport port -for local scraping using the `--otel-enable-prometheus-metrics-path` flag. +You can expose Prometheus-style metrics at `/metrics` using the +`--otel-enable-prometheus-metrics-path` flag. Metrics are served in two places: + +- The **diagnostics listener** on port `9464`, which is dedicated to Prometheus + scraping and carries no other traffic. +- The **transport port** (the same port MCP traffic uses), retained for + compatibility with existing scrape configurations. + +Governing scraper access by port with a firewall rule or Kubernetes +`NetworkPolicy` is only possible on the diagnostics listener, since the +transport port also serves MCP traffic. The diagnostics listener carries no +authentication middleware by design; restricting who can reach the port is what +protects it. This example runs the Fetch MCP server and enables the Prometheus metrics endpoint: @@ -173,18 +184,44 @@ endpoint: thv run --otel-enable-prometheus-metrics-path fetch ``` -To access the metrics, you can use `curl` or any Prometheus-compatible scraper. -The metrics are available at `http://127.0.0.1:/metrics`, where `` -is the port assigned to the MCP server. +To access the metrics on the diagnostics port: ```bash -# Get the port number assigned to the MCP server -thv list +curl http://127.0.0.1:9464/metrics +``` + +The same metrics are available on the MCP server's transport port at +`http://127.0.0.1:/metrics`, where `` is the port assigned to the +MCP server (visible in `thv list`). -# Replace with the actual port number from the output of `thv list` -curl http://127.0.0.1:/metrics +#### Move metrics off the transport port + +To stop serving `/metrics` on the transport port and only expose it on the +dedicated diagnostics listener, pass `--otel-metrics-on-transport-port=false`: + +```bash +thv run \ + --otel-enable-prometheus-metrics-path \ + --otel-metrics-on-transport-port=false \ + fetch ``` +With this flag set to `false`, the transport port returns `404` for `/metrics` +with a body explaining where the endpoint moved to. Under the transparent proxy +(used for `sse` and `streamable-http` container workloads and for remote-URL +runs), a backend that exposed its own `/metrics` through ToolHive is no longer +reachable at that path when the flag is off; scrape such backends directly +instead. + +:::info[Upcoming default change] + +`--otel-metrics-on-transport-port` currently defaults to `true` so existing +scrape configurations keep working. The default is planned to flip to `false` in +a future release. To inherit the new default automatically, leave the flag +unset. To opt out of the change, set it explicitly to `true`. + +::: + ### Dual export You can export to both an OTLP endpoint and expose Prometheus metrics @@ -234,6 +271,7 @@ thv run [--otel-endpoint ] [--otel-service-name ] \ | `--otel-env-vars` | List of environment variables to include in telemetry spans | None | | `--otel-insecure` | Connect using HTTP instead of HTTPS | `false` | | `--otel-enable-prometheus-metrics-path` | Enable `/metrics` endpoint | `false` | +| `--otel-metrics-on-transport-port` | Also serve `/metrics` on the MCP transport port | `true` | | `--otel-use-legacy-attributes` | Emit legacy attribute names alongside new OTel semantic conventions | `true` | ### Global configuration @@ -311,13 +349,14 @@ Prometheus configuration: scrape_configs: - job_name: 'toolhive-mcp-proxy' static_configs: - - targets: ['localhost:'] + - targets: ['localhost:9464'] scrape_interval: 15s metrics_path: /metrics ``` -You can add multiple MCP servers to the `targets` list. Replace -`` with the port number assigned to each MCP server. +`9464` is the dedicated diagnostics port that serves only `/metrics`. To scrape +the transport port instead, replace `9464` with the port number assigned to each +MCP server (from `thv list`). ### Jaeger @@ -463,17 +502,19 @@ If traces aren't showing up in your backend: If the `/metrics` endpoint isn't reachable: 1. Confirm the server was started with `--otel-enable-prometheus-metrics-path`. - You can verify by re-checking `thv list` and curling the URL it shows with - `/metrics` appended: + Metrics are served on the diagnostics port `9464` by default: ```bash - thv list - curl http://127.0.0.1:/metrics + curl http://127.0.0.1:9464/metrics ``` -2. The `/metrics` endpoint is served on the same proxy port as the MCP server. - If `curl` returns connection refused, the server isn't running on that port - at all - check `thv list` again and look for the actual port. +2. If you also expect `/metrics` on the transport port, confirm + `--otel-metrics-on-transport-port` was not set to `false`. When it is + `false`, the transport port returns `404` for `/metrics` with a body that + points at the diagnostics port. + +3. Look for a startup log line reporting the diagnostics address; if it did not + appear, the diagnostics listener never started. diff --git a/docs/toolhive/guides-k8s/mcp-server-entry.mdx b/docs/toolhive/guides-k8s/mcp-server-entry.mdx index 4ecfcfef..67e94e42 100644 --- a/docs/toolhive/guides-k8s/mcp-server-entry.mdx +++ b/docs/toolhive/guides-k8s/mcp-server-entry.mdx @@ -332,11 +332,39 @@ patterns. The operator rejects URLs that target: - **Loopback addresses**: `127.0.0.0/8`, `::1` - **Link-local addresses**: `169.254.0.0/16`, `fe80::/10` - **Cloud metadata endpoints**: `169.254.169.254` (AWS, GCP, Azure) -- **Private network ranges**: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` +- **`kubernetes.default*`** hostnames +- **Private network ranges**: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, + and IPv6 unique-local addresses If a URL fails SSRF validation, the entry's phase is set to `Failed` with a condition describing the rejection reason. +### Allow in-cluster backends + +Set `spec.allowPrivateEndpoint: true` to permit private network ranges, IPv6 +unique-local addresses, and `*.cluster.local` hostnames. Use this when vMCP +needs to reach a co-located backend inside the cluster so the backend's own +workload-identity authorization still applies. + +```yaml title="in-cluster-entry.yaml" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPServerEntry +metadata: + name: internal-tool + namespace: toolhive-system +spec: + groupRef: + name: my-group + remoteUrl: https://analytics-mcp.analytics.svc.cluster.local/mcp + transport: streamable-http + # highlight-next-line + allowPrivateEndpoint: true +``` + +Loopback, link-local, cloud-metadata, and `kubernetes.default*` targets remain +blocked regardless of `allowPrivateEndpoint`. Bare `.svc` hostnames are never +blocked because the operator does not resolve DNS during validation. + ## Next steps - [Configure a VirtualMCPServer](../guides-vmcp/configuration.mdx) to aggregate @@ -393,8 +421,10 @@ kubectl get mcpserverentry -n toolhive-system \ Common causes: - **SSRF validation failure**: The `remoteUrl` targets a blocked address range - (loopback, link-local, private network, or cloud metadata). Use an externally - routable URL + (loopback, link-local, cloud metadata, or `kubernetes.default*`). Use an + externally routable URL, or, for an in-cluster backend, set + `spec.allowPrivateEndpoint: true` to allow private network ranges and + `*.cluster.local` hostnames. - **Missing MCPGroup**: The group referenced in `groupRef` doesn't exist. Create the MCPGroup first - **Missing MCPExternalAuthConfig**: The auth config referenced in diff --git a/docs/toolhive/guides-k8s/rate-limiting.mdx b/docs/toolhive/guides-k8s/rate-limiting.mdx index 5e3a40fe..6a0dc828 100644 --- a/docs/toolhive/guides-k8s/rate-limiting.mdx +++ b/docs/toolhive/guides-k8s/rate-limiting.mdx @@ -255,6 +255,7 @@ latency histogram is exported with the `_seconds` unit suffix and the standard | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- | | `toolhive_rate_limit_decisions` | Counter | `decision` (`allowed` or `rejected`), `scope` (`shared` or `per_user`), `operation_type` (`server` or `tool`) | | `toolhive_rate_limit_redis_errors` | Counter | `error_type` (`timeout`, `connection`, `auth`, or `other`) | +| `toolhive_rate_limit_fail_open` | Counter | `error_type` (matches the `redis_errors` classification) | | `toolhive_rate_limit_check_latency` | Histogram | (none) | Counting semantics for `toolhive_rate_limit_decisions`: @@ -272,9 +273,11 @@ Counting semantics for `toolhive_rate_limit_decisions`: The `toolhive_rate_limit_check_latency` histogram records the duration of each Redis Lua call, whether the call succeeds or fails. Rate limit enforcement -[fails open](#how-rate-limiting-works) when Redis is unreachable, so watch -`toolhive_rate_limit_redis_errors` alongside decisions to detect fail-open -periods. +[fails open](#how-rate-limiting-works) when Redis is unreachable. +`toolhive_rate_limit_fail_open` increments each time enforcement chose to allow +a request after a Redis error, so alerting on it directly measures the +security-visible impact rather than every Redis blip. Traces for those requests +carry a `rate_limit.fail_open` span attribute set to `true`. Example PromQL queries against a Prometheus scrape of the server's `/metrics` endpoint: diff --git a/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx b/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx index 8d341e6f..7b08ee3f 100644 --- a/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx +++ b/docs/toolhive/guides-k8s/remote-mcp-proxy.mdx @@ -455,17 +455,74 @@ via `AssumeRoleWithWebIdentity` and signs requests with SigV4. See the ::: +### Allow in-cluster backends + +By default, the operator rejects `remoteUrl` values that resolve to private +network ranges, IPv6 unique-local addresses, or `*.cluster.local` hostnames to +prevent Server-Side Request Forgery (SSRF) against internal services. To proxy a +co-located backend inside the cluster, set `spec.allowPrivateEndpoint: true`: + +```yaml {6} +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPRemoteProxy +metadata: + name: internal-mcp-proxy + namespace: toolhive-system +spec: + allowPrivateEndpoint: true + remoteUrl: https://analytics-mcp.analytics.svc.cluster.local/mcp + proxyPort: 8080 + transport: streamable-http +``` + +Loopback, link-local, cloud-metadata, and `kubernetes.default*` targets remain +blocked regardless of `allowPrivateEndpoint`. + ### Customize the remote proxy pod -Use `podTemplateSpec` to set pod-level options that aren't exposed as -first-class fields on `MCPRemoteProxy`, such as security contexts, node -selectors, tolerations, and affinity rules. The field follows the standard -Kubernetes +You have two ways to influence how the proxy pod is scheduled and configured: + +- **`resourceOverrides.proxyDeployment`** for first-class scheduling fields + (`nodeSelector`, `tolerations`, `affinity`) and Deployment metadata. +- **`podTemplateSpec`** for anything else the Deployment's pod template needs + (security contexts, sidecar containers, volumes). + +If both are set, the `podTemplateSpec` `nodeSelector` and `affinity` sub-fields +win on any keys they specify, and `podTemplateSpec.tolerations` replaces the +list from `resourceOverrides.proxyDeployment.tolerations` outright. + +To pin the proxy Deployment to a specific node pool without writing a full +`podTemplateSpec`: + +```yaml {6-19} title="analytics-proxy-scheduling.yaml" +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPRemoteProxy +metadata: + name: analytics-proxy + namespace: toolhive-system +spec: + resourceOverrides: + proxyDeployment: + nodeSelector: + workload-tier: platform + tolerations: + - key: dedicated + operator: Equal + value: platform + effect: NoSchedule + remoteUrl: https://mcp.analytics.example.com + proxyPort: 8080 + transport: streamable-http +``` + +Use `podTemplateSpec` for pod-level options that aren't exposed as first-class +fields on `MCPRemoteProxy`, such as security contexts. The field follows the +standard Kubernetes [`PodTemplateSpec`](https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-template-v1/#PodTemplateSpec) format, and you only need to specify the fields you want to add or override. This example sets resource limits on the proxy container and adds a node -selector: +selector via `podTemplateSpec`: ```yaml {15-27} title="analytics-proxy-custom-pod.yaml" apiVersion: toolhive.stacklok.dev/v1beta1 diff --git a/docs/toolhive/guides-k8s/run-mcp-k8s.mdx b/docs/toolhive/guides-k8s/run-mcp-k8s.mdx index cd2f72a0..0c418d8f 100644 --- a/docs/toolhive/guides-k8s/run-mcp-k8s.mdx +++ b/docs/toolhive/guides-k8s/run-mcp-k8s.mdx @@ -519,7 +519,8 @@ The field has two sub-objects: - `proxyDeployment` - overrides for the proxy Deployment. Supports `labels` and `annotations` on the Deployment itself, `podTemplateMetadataOverrides` (`labels` and `annotations` applied to the proxy pod template), `env` - (environment variables for the proxy container), and `imagePullSecrets`. + (environment variables for the proxy container), `imagePullSecrets`, and the + scheduling fields `nodeSelector`, `tolerations`, and `affinity`. - `proxyService` - `labels` and `annotations` for the proxy Service. ```yaml title="my-mcpserver-resource-overrides.yaml" @@ -568,6 +569,32 @@ Common uses: secrets in `proxyDeployment.imagePullSecrets` are added to the proxy Deployment's pod spec, and to the operator-managed ServiceAccount when the operator creates one. +- **Scheduling** with `proxyDeployment.nodeSelector`, + `proxyDeployment.tolerations`, and `proxyDeployment.affinity` lands the proxy + Deployment on a pre-warmed or dedicated node pool. Use these when the proxy + should follow specific hardware or licensing constraints: + + ```yaml {6-16} + spec: + image: ghcr.io/stackloklabs/osv-mcp/server + transport: streamable-http + mcpPort: 8080 + proxyPort: 8080 + resourceOverrides: + proxyDeployment: + nodeSelector: + workload-tier: platform + tolerations: + - key: dedicated + operator: Equal + value: platform + effect: NoSchedule + ``` + + If `podTemplateSpec` also sets scheduling fields, + `podTemplateSpec.nodeSelector`/`affinity` sub-fields win on any keys they + specify and `podTemplateSpec.tolerations` replaces the list from + `resourceOverrides.proxyDeployment` outright. ## Check MCP server status diff --git a/docs/toolhive/guides-k8s/telemetry-and-metrics.mdx b/docs/toolhive/guides-k8s/telemetry-and-metrics.mdx index de2d08da..b879786b 100644 --- a/docs/toolhive/guides-k8s/telemetry-and-metrics.mdx +++ b/docs/toolhive/guides-k8s/telemetry-and-metrics.mdx @@ -136,10 +136,48 @@ requests traced, as a quoted string between `'0'` and `'1.0'`. The default is `'0.05'` (5%). To expose a Prometheus-compatible `/metrics` endpoint for pull-based scraping, -enable `spec.prometheus.enabled`. Access the metrics at -`http://:/metrics`, where `` is the resolvable address of the -ToolHive ProxyRunner fronting your MCP server pod and `` is the port the -ProxyRunner service exposes for traffic. +enable `spec.prometheus.enabled`. Prometheus metrics are served on a dedicated +diagnostics listener on port `9464`. The ProxyRunner pod also serves `/metrics` +on the transport port by default for compatibility with existing scrapers. + +To scrape the diagnostics port, target the pod directly using +`kubernetes_sd_configs` with `role: pod` and relabel the address to `:9464`. The +port has no Service or `containerPort` declaration, so `ServiceMonitor` and +named-port `PodMonitor` discovery cannot find it. + +To stop serving `/metrics` on the transport port, set +`spec.prometheus.metricsOnTransportPort: false` on the `MCPTelemetryConfig`. The +transport-port default is expected to flip to `false` in a future release; +leaving the field unset inherits the release default so the change reaches +existing workloads automatically. + +The diagnostics listener has no built-in authentication. Restrict access with a +Kubernetes `NetworkPolicy` that allows only your Prometheus scraper to reach +port `9464` on ToolHive pods. + +```yaml title="scrape-network-policy.yaml" +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-prometheus-scrape + namespace: toolhive-system +spec: + podSelector: + matchLabels: + toolhive: 'true' + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + podSelector: + matchLabels: + app.kubernetes.io/name: prometheus + ports: + - protocol: TCP + port: 9464 +``` #### Authentication headers @@ -296,10 +334,10 @@ spec: ### Prometheus -This example scrapes the `/metrics` endpoint exposed by each MCP server -directly. To aggregate metrics through an OpenTelemetry Collector instead -(ToolHive pushes to the collector, Prometheus scrapes the collector), see the -[OpenTelemetry Collector](#opentelemetry-collector-recommended) section. +This example scrapes the `/metrics` endpoint exposed by each MCP server directly +on the diagnostics port. To aggregate metrics through an OpenTelemetry Collector +instead (ToolHive pushes to the collector, Prometheus scrapes the collector), +see the [OpenTelemetry Collector](#opentelemetry-collector-recommended) section. To enable scraping, [enable Prometheus](#configuration-details) in your telemetry configuration and add the following to your Prometheus configuration: @@ -307,15 +345,25 @@ telemetry configuration and add the following to your Prometheus configuration: ```yaml title="prometheus.yml" scrape_configs: - job_name: 'toolhive-mcp-proxy' - static_configs: - - targets: [':'] + kubernetes_sd_configs: + - role: pod + namespaces: + names: [toolhive-system] + relabel_configs: + - source_labels: [__meta_kubernetes_pod_label_toolhive] + action: keep + regex: 'true' + - source_labels: [__meta_kubernetes_pod_ip] + target_label: __address__ + replacement: '$1:9464' scrape_interval: 15s metrics_path: /metrics ``` -Add multiple MCP servers to the `targets` list. Replace -`` with the ProxyRunner SVC name and -`` with the port number exposed by the SVC. +The diagnostics port `9464` is not declared as a `containerPort` or Service +port, so `ServiceMonitor` and named-port `PodMonitor` discovery cannot find it. +Use pod-based service discovery as shown, or scrape the transport port directly +while `metricsOnTransportPort` is enabled. ### Jaeger diff --git a/docs/toolhive/guides-vmcp/configuration.mdx b/docs/toolhive/guides-vmcp/configuration.mdx index f4d7fe74..0e936e23 100644 --- a/docs/toolhive/guides-vmcp/configuration.mdx +++ b/docs/toolhive/guides-vmcp/configuration.mdx @@ -271,6 +271,19 @@ spec: fast-backend: 10s ``` +:::info[Changed in v0.45.0] + +`operational.timeouts.default` and `perWorkload` values are now applied to +backend calls; earlier releases accepted the fields but always used a 30s +default. Values below 30s that were previously silently ignored will now cut +slow requests. If you had configured a short value as aspiration, either raise +it back to `30s` to preserve the earlier behavior or verify your slowest +`tools/call` completes within the window. Values above 30s now hold an in-flight +request handler and an upstream connection for the full duration; size replica +count and connection limits accordingly. + +::: + :::note Health check timeouts are configured separately via diff --git a/docs/toolhive/guides-vmcp/telemetry-and-metrics.mdx b/docs/toolhive/guides-vmcp/telemetry-and-metrics.mdx index 2d38093b..3e012f7b 100644 --- a/docs/toolhive/guides-vmcp/telemetry-and-metrics.mdx +++ b/docs/toolhive/guides-vmcp/telemetry-and-metrics.mdx @@ -75,15 +75,26 @@ and triggers a rolling update of affected deployments. ### MCPTelemetryConfig fields -| Field | Description | Default | -| ----------------------------------------- | --------------------------------------- | -------- | -| `spec.openTelemetry.enabled` | Enable OpenTelemetry export | `false` | -| `spec.openTelemetry.endpoint` | OTLP collector endpoint (hostname:port) | - | -| `spec.openTelemetry.insecure` | Use HTTP instead of HTTPS | `false` | -| `spec.openTelemetry.tracing.enabled` | Enable tracing | `false` | -| `spec.openTelemetry.tracing.samplingRate` | Trace sampling rate (0.0-1.0) | `"0.05"` | -| `spec.openTelemetry.metrics.enabled` | Enable OTLP metrics export | `false` | -| `spec.prometheus.enabled` | Expose `/metrics` endpoint | `false` | +| Field | Description | Default | +| ----------------------------------------- | ------------------------------------------------ | -------- | +| `spec.openTelemetry.enabled` | Enable OpenTelemetry export | `false` | +| `spec.openTelemetry.endpoint` | OTLP collector endpoint (hostname:port) | - | +| `spec.openTelemetry.insecure` | Use HTTP instead of HTTPS | `false` | +| `spec.openTelemetry.tracing.enabled` | Enable tracing | `false` | +| `spec.openTelemetry.tracing.samplingRate` | Trace sampling rate (0.0-1.0) | `"0.05"` | +| `spec.openTelemetry.metrics.enabled` | Enable OTLP metrics export | `false` | +| `spec.prometheus.enabled` | Expose `/metrics` endpoint | `false` | +| `spec.prometheus.metricsOnTransportPort` | Also serve `/metrics` on the vMCP transport port | `true` | + +Prometheus metrics are served on the vMCP pod's dedicated diagnostics port +`9464`. Set `spec.prometheus.metricsOnTransportPort: false` to stop duplicating +the endpoint on the vMCP transport port; the transport-port default is expected +to flip in a future release. + +For inline `VirtualMCPServer` telemetry (`spec.config.telemetry`), the same +`metricsOnTransportPort` field applies, and an optional +`spec.config.telemetry.prometheusPort` overrides the diagnostics port (`9464` by +default). The `MCPTelemetryConfig` resource does not expose a port override. ## Export to observability backends @@ -141,9 +152,13 @@ spec: vMCP supports two methods for collecting metrics: - **Push via OpenTelemetry**: Set `spec.openTelemetry.metrics.enabled: true` to - push metrics to your OTel Collector via OTLP + push metrics to your OTel Collector via OTLP. - **Pull via Prometheus**: Set `spec.prometheus.enabled: true` to expose a - `/metrics` endpoint on the vMCP service port (4483) for Prometheus to scrape + `/metrics` endpoint on the dedicated diagnostics port (`9464` by default, + overridable via `spec.config.telemetry.prometheusPort` on the + `VirtualMCPServer`). By default, the endpoint is also served on the vMCP + transport port for compatibility; set `metricsOnTransportPort: false` to + disable the duplicate. ### Backend metrics @@ -178,6 +193,7 @@ the Redis backend that stores the buckets. Each metric carries `namespace` and | ----------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `toolhive_rate_limit_decisions` | Counter | Bucket decisions. Additional attributes: `decision` (`allowed` or `rejected`), `scope` (`shared` or `per_user`), `operation_type` (`server` or `tool`) | | `toolhive_rate_limit_redis_errors` | Counter | Redis errors during rate limit checks. Additional attribute: `error_type` (`timeout`, `connection`, `auth`, or `other`) | +| `toolhive_rate_limit_fail_open` | Counter | Requests allowed because the rate limit check failed open after a Redis error. Additional attribute: `error_type` (matches `redis_errors`) | | `toolhive_rate_limit_check_latency` | Histogram | Duration of the atomic Redis Lua rate limit check, in seconds (each check, including failures) | For the counting semantics (allowed increments per bucket, rejected only for the diff --git a/docs/toolhive/reference/cli/thv_ai-plugin.md b/docs/toolhive/reference/cli/thv_ai-plugin.md index 1df12af5..c2bc6ec5 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin.md @@ -39,6 +39,8 @@ The ai-plugin command provides subcommands to manage plugins for AI tools * [thv ai-plugin install](thv_ai-plugin_install.md) - Install an AI-tool plugin * [thv ai-plugin list](thv_ai-plugin_list.md) - List installed AI-tool plugins * [thv ai-plugin push](thv_ai-plugin_push.md) - Push a built AI-tool plugin to an OCI registry +* [thv ai-plugin sync](thv_ai-plugin_sync.md) - Restore project plugins to match the lock file * [thv ai-plugin uninstall](thv_ai-plugin_uninstall.md) - Uninstall an AI-tool plugin +* [thv ai-plugin upgrade](thv_ai-plugin_upgrade.md) - Upgrade project plugins to newer pinned content * [thv ai-plugin validate](thv_ai-plugin_validate.md) - Validate an AI-tool plugin directory diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_install.md b/docs/toolhive/reference/cli/thv_ai-plugin_install.md index bb4c04e4..bb5636c1 100644 --- a/docs/toolhive/reference/cli/thv_ai-plugin_install.md +++ b/docs/toolhive/reference/cli/thv_ai-plugin_install.md @@ -25,6 +25,7 @@ thv ai-plugin install [plugin-name] [flags] ### Options ``` + --allow-unsigned Allow installing a project-scoped plugin without a verified signature (recorded in the lock file) --clients string Comma-separated target client apps (e.g. claude-code,codex), or "all" for every available client --force Overwrite existing plugin directory --group string Group to add the plugin to after installation diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_sync.md b/docs/toolhive/reference/cli/thv_ai-plugin_sync.md new file mode 100644 index 00000000..36b47fdc --- /dev/null +++ b/docs/toolhive/reference/cli/thv_ai-plugin_sync.md @@ -0,0 +1,58 @@ +--- +title: thv ai-plugin sync +hide_title: true +description: Reference for ToolHive CLI command `thv ai-plugin sync` +last_update: + author: autogenerated +slug: thv_ai-plugin_sync +mdx: + format: md +--- + +## thv ai-plugin sync + +Restore project plugins to match the lock file + +### Synopsis + +Restore a project's installed plugins to match toolhive.lock.yaml. + +Missing or drifted plugins are reinstalled at their pinned digest. Use +--check to report drift without installing anything (suitable for CI). +Use --adopt to record lock entries for existing unmanaged installs, and +--prune to remove installs no longer present in the lock file. + +Unless --check is set, sync prompts for confirmation before installing — +plugin content is a set of AI-followed instructions. Pass --yes to skip the +prompt (required in non-interactive contexts such as CI). + +Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true. + +``` +thv ai-plugin sync [flags] +``` + +### Options + +``` + --adopt Write lock entries for existing unmanaged project-scope installs + --allow-unsigned Allow adopting plugins whose signature state cannot be established (recorded as unsigned) + --check Report drift without installing, writing, or removing anything + --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client + --format string Output format (json, text) (default "text") + -h, --help help for sync + --project-root string Project root path (default: auto-detected from the current directory) + --prune Remove installs no longer present in the lock file + --yes Skip the confirmation prompt (required when not running interactively) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv ai-plugin](thv_ai-plugin.md) - Manage AI-tool plugins + diff --git a/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md b/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md new file mode 100644 index 00000000..9de7e92f --- /dev/null +++ b/docs/toolhive/reference/cli/thv_ai-plugin_upgrade.md @@ -0,0 +1,62 @@ +--- +title: thv ai-plugin upgrade +hide_title: true +description: Reference for ToolHive CLI command `thv ai-plugin upgrade` +last_update: + author: autogenerated +slug: thv_ai-plugin_upgrade +mdx: + format: md +--- + +## thv ai-plugin upgrade + +Upgrade project plugins to newer pinned content + +### Synopsis + +Re-resolve a project's lock entries and install newer content where available. + +Plugins pinned to an immutable reference (an OCI digest or a full git commit +hash) are reported not-upgradable — there is nothing newer to resolve to. +Use --preview to see what would change without persisting anything (OCI +sources are still fetched into the local artifact store to compare digests), +and --allow-ref-change to permit the artifact moving to a different +repository (a version bump within the same repository is not a change +this guard blocks). +--fail-on-changes evaluates the same plan and never installs: it is a CI +freshness gate. + +Unless --preview is set, upgrade prompts for confirmation before installing — +plugin content is a set of AI-followed instructions. Pass --yes to skip the +prompt (required in non-interactive contexts such as CI). + +Requires TOOLHIVE_PLUGINS_LOCK_ENABLED=true. + +``` +thv ai-plugin upgrade [plugin-name...] [flags] +``` + +### Options + +``` + --allow-ref-change Permit the artifact to move to a different repository during upgrade + --clients string Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client + --fail-on-changes Report what would change without installing anything; a CI freshness gate + --format string Output format (json, text) (default "text") + -h, --help help for upgrade + --preview Report what would change without persisting anything (OCI sources are still fetched to compare digests) + --project-root string Project root path (default: auto-detected from the current directory) + --yes Skip the confirmation prompt (required when not running interactively) +``` + +### Options inherited from parent commands + +``` + --debug Enable debug mode +``` + +### SEE ALSO + +* [thv ai-plugin](thv_ai-plugin.md) - Manage AI-tool plugins + diff --git a/docs/toolhive/reference/cli/thv_client_register.md b/docs/toolhive/reference/cli/thv_client_register.md index 0ce5cfeb..0cef577f 100644 --- a/docs/toolhive/reference/cli/thv_client_register.md +++ b/docs/toolhive/reference/cli/thv_client_register.md @@ -34,6 +34,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor + - qoder: Qoder IDE - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code diff --git a/docs/toolhive/reference/cli/thv_client_remove.md b/docs/toolhive/reference/cli/thv_client_remove.md index c5bb53a5..52ec371f 100644 --- a/docs/toolhive/reference/cli/thv_client_remove.md +++ b/docs/toolhive/reference/cli/thv_client_remove.md @@ -34,6 +34,7 @@ Valid clients: - lm-studio: LM Studio application - mistral-vibe: Mistral Vibe IDE - opencode: OpenCode editor + - qoder: Qoder IDE - roo-code: VS Code Roo Code extension (deprecated) - trae: Trae IDE - vscode: Visual Studio Code diff --git a/docs/toolhive/reference/cli/thv_run.md b/docs/toolhive/reference/cli/thv_run.md index 1c8638d4..6402c980 100644 --- a/docs/toolhive/reference/cli/thv_run.md +++ b/docs/toolhive/reference/cli/thv_run.md @@ -144,12 +144,13 @@ thv run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...] --oidc-jwks-url string URL to fetch the JWKS from --oidc-scopes strings OAuth scopes to advertise in the well-known endpoint (RFC 9728, defaults to 'openid' if not specified) --otel-custom-attributes string Custom resource attributes for OpenTelemetry in key=value format (e.g., server_type=prod,region=us-east-1,team=platform) - --otel-enable-prometheus-metrics-path Enable Prometheus-style /metrics endpoint on the main transport port (default false) + --otel-enable-prometheus-metrics-path Enable Prometheus-style /metrics endpoint on a dedicated diagnostics port (default false) --otel-endpoint string OpenTelemetry OTLP endpoint URL (e.g., https://api.honeycomb.io) --otel-env-vars stringArray Environment variable names to include in OpenTelemetry spans (comma-separated: ENV1,ENV2) --otel-headers stringArray OpenTelemetry OTLP headers in key=value format (e.g., x-honeycomb-team=your-api-key) --otel-insecure Connect to the OpenTelemetry endpoint using HTTP instead of HTTPS (default false) --otel-metrics-enabled Enable OTLP metrics export (when OTLP endpoint is configured) (default true) + --otel-metrics-on-transport-port Also serve Prometheus /metrics on the transport port, alongside the diagnostics port. Deprecated: this is a migration aid and the default will become false; see https://github.com/stacklok/toolhive/issues/6384 for the timeline. Move scrapers to the diagnostics port and set this to false to verify. (default true) --otel-sampling-rate float OpenTelemetry trace sampling rate (0.0-1.0) (default 0.1) --otel-service-name string OpenTelemetry service name (defaults to thv-) --otel-tracing-enabled Enable distributed tracing (when OTLP endpoint is configured) (default true) diff --git a/docs/toolhive/reference/cli/thv_skill_push.md b/docs/toolhive/reference/cli/thv_skill_push.md index 9a34faa2..fbe5895d 100644 --- a/docs/toolhive/reference/cli/thv_skill_push.md +++ b/docs/toolhive/reference/cli/thv_skill_push.md @@ -24,9 +24,10 @@ thv skill push [reference] [flags] ### Options ``` - -h, --help help for push - --key string Path to a cosign private key to sign the pushed artifact. Encrypted keys are decrypted with COSIGN_PASSWORD read from the 'thv serve' process, which performs the signing - --no-sign Push without signing (consumers will need an explicit unsigned exception to install project-scoped) + -h, --help help for push + --identity-token string OIDC identity token (or a path to a file containing one) for keyless signing. Mutually exclusive with --key. If omitted, one is acquired automatically: from the ambient CI OIDC token when running with id-token: write permission, otherwise via an interactive browser sign-in + --key string Path to a cosign private key to sign the pushed artifact. Encrypted keys are decrypted with COSIGN_PASSWORD read from the 'thv serve' process, which performs the signing + --no-sign Push without signing (consumers will need an explicit unsigned exception to install project-scoped) ``` ### Options inherited from parent commands diff --git a/docs/toolhive/reference/client-compatibility.mdx b/docs/toolhive/reference/client-compatibility.mdx index b6e2176c..a92debe0 100644 --- a/docs/toolhive/reference/client-compatibility.mdx +++ b/docs/toolhive/reference/client-compatibility.mdx @@ -31,6 +31,7 @@ We've tested ToolHive with these clients: | Mistral Vibe | ✅ | ✅ | ✅ | | | OpenAI Codex | ✅ | ✅ | ✅ | | | OpenCode | ✅ | ✅ | ✅ | | +| Qoder IDE | ✅ | ✅ | ✅ | | | Roo Code (VS Code) | ✅ | ✅ | ✅ | Deprecated ([see note][6]) | | Sourcegraph Amp CLI | ✅ | ✅ | ✅ | | | Trae IDE | ✅ | ✅ | ✅ | v1.4.1+ | diff --git a/static/api-specs/skill.schema.json b/static/api-specs/skill.schema.json index 2550e7e7..251798e3 100644 --- a/static/api-specs/skill.schema.json +++ b/static/api-specs/skill.schema.json @@ -85,6 +85,9 @@ "$ref": "#/$defs/skill_package" } }, + "provenance": { + "$ref": "#/$defs/provenance" + }, "metadata": { "type": "object", "description": "Official metadata from the SKILL.md file", @@ -168,6 +171,75 @@ } } }, + "provenance": { + "type": "object", + "description": "Expected provenance for this skill, checked on first install instead of trust-on-first-use. Every field is optional, and an empty string leaves that dimension unconstrained. The exception is 'attestation', where the key's presence is itself the constraint: see its own description. Mirrors the server provenance definition in publisher-provided.schema.json, with two deliberate differences. First, no 'format' keywords, because the Go Provenance struct serializes unset fields as empty strings and an empty string does not satisfy 'uri' or 'hostname'. Second, 'additionalProperties' is false, so a misspelled constraint key is a loud error rather than a silently dropped guarantee.", + "additionalProperties": false, + "properties": { + "sigstore_url": { + "type": "string", + "description": "Sigstore TUF repository host for provenance verification", + "examples": [ + "tuf-repo.github.com", + "tuf-repo-cdn.sigstore.dev" + ] + }, + "repository_uri": { + "type": "string", + "description": "Repository URI used for provenance verification" + }, + "repository_ref": { + "type": "string", + "description": "Repository reference used for provenance verification" + }, + "signer_identity": { + "type": "string", + "description": "Identity of the signer for provenance verification", + "examples": [ + "/.github/workflows/build-skills.yml" + ] + }, + "runner_environment": { + "type": "string", + "description": "Build environment where the skill was built", + "examples": [ + "github-hosted", + "gitlab-hosted", + "self-hosted" + ] + }, + "cert_issuer": { + "type": "string", + "description": "Certificate issuer for provenance verification", + "examples": [ + "https://token.actions.githubusercontent.com" + ] + }, + "attestation": { + "$ref": "#/$defs/verified_attestation" + } + } + }, + "verified_attestation": { + "type": "object", + "description": "Expected in-toto attestation. Declaring this key at all requires the artifact to carry an attestation: verification fails against a signature that has none. Both members are optional and constrain independently, so an empty object means 'must be attested, no constraint on what the attestation says'.", + "additionalProperties": false, + "properties": { + "predicate_type": { + "type": "string", + "description": "Expected predicate type. When set, it must equal the predicate type of the statement carried by the signature.", + "examples": [ + "https://slsa.dev/provenance/v0.2", + "https://slsa.dev/provenance/v1" + ] + }, + "predicate": { + "type": "object", + "description": "Expected predicate body, compared for deep equality against the statement predicate. Constrained to an object because an in-toto predicate is always one: the verifier normalizes the expected value through structpb before comparing, and a non-object expectation fails that step, so it could never match any artifact.", + "additionalProperties": true + } + } + }, "skill_repository": { "type": "object", "description": "Source repository metadata", diff --git a/static/api-specs/toolhive-api.yaml b/static/api-specs/toolhive-api.yaml index 161320f0..37854572 100644 --- a/static/api-specs/toolhive-api.yaml +++ b/static/api-specs/toolhive-api.yaml @@ -634,6 +634,20 @@ components: secrets to any caller. Combining it with InsecureAllowHTTP is rejected by Validate. type: boolean + allow_private_key_jwt_registration: + description: |- + AllowPrivateKeyJWTRegistration permits Dynamic Client Registration of + clients using private_key_jwt authentication. This is independent of + AllowConfidentialClientRegistration and defaults to false. Registration + behavior is controlled independently by the DCR handler and discovery + metadata. + + Security: /oauth/register is unauthenticated. Unlike + AllowConfidentialClientRegistration, this is NOT rejected when combined + with InsecureAllowHTTP: registration never returns a secret for a + private_key_jwt client, so there is nothing for cleartext HTTP to + expose. + type: boolean allowed_audiences: description: |- AllowedAudiences is the list of valid resource URIs that tokens can be issued for. @@ -752,10 +766,15 @@ components: secrets would otherwise travel over cleartext. Defaults to false. Has no effect when there are no confidential clients or Issuer is https. - Applies identically to delegate clients and DCR-registered clients; the - Kubernetes CRD blocks this combination unconditionally only because CEL - cannot express the loopback exception, not because delegate clients need - a stricter policy — see EmbeddedAuthServerConfig's doc comment. + Applies identically to delegate clients and DCR-registered clients. The + Kubernetes CRD requires the explicit opt-in for a delegate client with an + HTTP issuer; the shared transport validator enforces that its host is + loopback — see EmbeddedAuthServerConfig's doc comment. + + private_key_jwt registration has no equivalent flag or transport + restriction: unlike confidential registration, it never returns a + client_secret (or any other secret) in the DCR response, so there is + nothing here for cleartext HTTP to expose. type: boolean insecure_allow_http: description: |- @@ -789,8 +808,10 @@ components: trusted_issuers: description: |- TrustedIssuers lists external OIDC issuers whose tokens are accepted as - subject tokens during RFC 8693 token exchange. Empty (the default) means - only self-issued subject tokens are accepted. + RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions. Issuers with + jwtBearerGrant enabled may be used for the JWT-bearer grant without an + RFC 8693 delegation policy. Empty (the default) means only self-issued + subject tokens are accepted. See tokenexchange.TrustedIssuer for the per-issuer field reference, and docs/arch/17-token-exchange-delegation.md for the trust model, consent @@ -964,6 +985,46 @@ components: If not specified, defaults to GET. type: string type: object + github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy: + description: |- + JWTBearerGrant optionally enables the plain RFC 7523 JWT-bearer grant. + It accepts assertions from this issuer without client authentication and + limits their maximum age, subjects, and RFC 8707 resources. It is + independent from RFC 8693 delegation policy. + properties: + accepted_audiences: + description: |- + AcceptedAudiences is the set of "this AS" identity strings an + assertion's "aud" claim must intersect — e.g. to support migrating + this server's issuer/token-endpoint URL, or exposing it under more + than one valid name. Each value uniquely identifies this + authorization server for this grant; it is NOT a resource/API + identifier — a bare resource audience is deliberately not accepted + here, that would let any RFC 8707 resource-scoped token satisfy the + grant instead of only tokens minted for this AS. Defaults to + [tokenEndpoint] when empty, preserving prior exact-match behavior. + items: + type: string + type: array + uniqueItems: false + max_assertion_age: + type: string + subject_bindings: + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding' + type: array + uniqueItems: false + type: object + github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerSubjectBinding: + properties: + allowed_resources: + items: + type: string + type: array + uniqueItems: false + subject: + type: string + type: object github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.TrustedIssuer: properties: actor_claim: @@ -976,13 +1037,26 @@ components: instead of Extra (assignClaim routes it to that field) — it is still the external token's client_id claim, not a ToolHive one. type: string + actor_matcher: + description: |- + ActorMatcher is an admin-authored CEL expression evaluated against the + complete signature-verified JWT claims map as "claims". A true result + authorizes delegation alongside AllowedActors; a syntax or type error + fails configuration validation. An expression that compiles but does + not return bool is NOT caught at that point, though — it compiles + successfully and is only rejected the first time it is evaluated + against a real token, denying that token (and every one after it, since + the expression will never return bool). Any other runtime evaluation + error denies the token the same way. + type: string allow_may_act: description: |- AllowMayAct permits this external issuer's may_act claim to authorize delegation. It defaults to false; external issuers must be opted in - explicitly because may_act bypasses AllowedActors. It does not affect - self-issued subject tokens. When enabled, AllowedDelegateClients must - name specific ToolHive clients rather than use the wildcard. + explicitly because may_act bypasses AllowedActors and ActorMatcher. It + does not affect self-issued subject tokens. When enabled, + AllowedDelegateClients must name specific ToolHive clients rather than + use the wildcard. type: boolean allow_private_ips: description: |- @@ -994,8 +1068,10 @@ components: description: |- AllowedActors is the allowlist of ActorClaim values authorized to exchange a subject token from this issuer when it carries no - "may_act" claim. Empty denies every token unless AllowMayAct is true - and the token carries a permitted may_act claim. By itself names no + "may_act" claim. ActorMatcher can additionally authorize a token by + matching its complete verified claims map; either signal is sufficient. + When both are empty, only may_act-bearing tokens are accepted, and only + if AllowMayAct is also true for this issuer. By itself names no ToolHive client — see AllowedDelegateClients and docs/arch/17-token-exchange-delegation.md ("Accepted limitations" #1). items: @@ -1016,8 +1092,10 @@ components: expected_audience: description: |- ExpectedAudience is the expected "aud" claim value that must appear - in the token's audience list (a resource/API identifier, not a - client ID — required, but not enforced; see looksLikeResourceIdentifier). + in an RFC 8693 subject token's audience list (a resource/API identifier, + not a client ID — required for delegation unless JWTBearerGrant is + configured; see looksLikeResourceIdentifier). RFC 7523 assertions use + the token endpoint as their audience instead. See docs/arch/17-token-exchange-delegation.md ("ID/access-token discrimination") for why and its limits. type: string @@ -1040,6 +1118,8 @@ components: JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. If empty, it is resolved via OIDC discovery at {IssuerURL}/.well-known/openid-configuration. type: string + jwt_bearer_grant: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_authserver_server_tokenexchange.JWTBearerGrantPolicy' type: object github_com_stacklok_toolhive_pkg_authz.Config: description: |- @@ -1080,6 +1160,7 @@ components: - kimi-cli - factory - copilot-cli + - qoder type: string x-enum-varnames: - RooCode @@ -1106,6 +1187,7 @@ components: - KimiCli - Factory - CopilotCli + - Qoder github_com_stacklok_toolhive_pkg_client.ClientAppStatus: properties: client_type: @@ -1381,6 +1463,79 @@ components: x-enum-varnames: - ScopeUser - ScopeProject + github_com_stacklok_toolhive_pkg_plugins.SyncResult: + properties: + already_current: + description: AlreadyCurrent lists skills that already matched the lock file. + items: + type: string + type: array + uniqueItems: false + drifted: + description: |- + Drifted lists skills whose on-disk contentDigest differed from the lock + file. Normally these are reinstalled to match it; when Check is set, + nothing is written and this field reports the drift only. + items: + type: string + type: array + uniqueItems: false + failed: + description: |- + Failed lists skills that could not be synced, with the reason for each. + Drift alone is never reported here — see Drifted. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.SyncFailure' + type: array + uniqueItems: false + installed: + description: Installed lists skills that were installed or reinstalled to + match the lock file. + items: + type: string + type: array + uniqueItems: false + missing: + description: |- + Missing lists lock entries with no corresponding install record at all + — the fresh-clone state. Normally these are installed at their pinned + reference; when Check is set, nothing is written and this field + reports the gap only. + items: + type: string + type: array + uniqueItems: false + never_managed: + description: NeverManaged lists project-scoped skills never recorded as + lock-managed. + items: + type: string + type: array + uniqueItems: false + pruned: + description: Pruned lists removed-from-lock skills that were uninstalled + because Prune was set. + items: + type: string + type: array + uniqueItems: false + removed_from_lock: + description: RemovedFromLock lists previously managed skills absent from + the lock file. + items: + type: string + type: array + uniqueItems: false + type: object + github_com_stacklok_toolhive_pkg_plugins.UpgradeResult: + properties: + outcomes: + description: Outcomes contains one entry per skill considered for upgrade. + items: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_skills.UpgradeOutcome' + type: array + uniqueItems: false + type: object github_com_stacklok_toolhive_pkg_plugins.ValidationResult: properties: errors: @@ -3061,6 +3216,12 @@ components: pkg_api_v1.installPluginRequest: description: Request to install a plugin properties: + allow_unsigned: + description: |- + AllowUnsigned permits installing a project-scoped plugin without a + verified signature; the exception is recorded in the project's lock + file. + type: boolean clients: description: |- Clients lists target client identifiers (e.g., "claude-code"), @@ -3269,6 +3430,11 @@ components: pkg_api_v1.pushSkillRequest: description: Request to push a built skill artifact properties: + identity_token: + description: |- + IdentityToken is a short-lived OIDC identity token used for keyless + signing, mutually exclusive with Key + type: string key: description: |- Key is the path to a cosign private key used to sign the pushed @@ -3449,6 +3615,40 @@ components: type: array uniqueItems: false type: object + pkg_api_v1.syncPluginsRequest: + description: Request to restore a project's installed plugins to match its lock + file + properties: + adopt: + description: Adopt writes lock entries for existing unmanaged project-scope + installs + type: boolean + allow_unsigned: + description: |- + AllowUnsigned permits adopting plugins whose signature state cannot be + established, recording them as unsigned + type: boolean + check: + description: Check verifies on-disk content against the lock file without + installing or writing anything + type: boolean + clients: + description: |- + Clients lists target client identifiers. Empty means every + plugin-supporting client detected on this host. + items: + type: string + type: array + uniqueItems: false + project_root: + description: ProjectRoot is the project root path whose lock file should + be synced + type: string + prune: + description: Prune removes project-scoped plugins installed but not present + in the lock file + type: boolean + type: object pkg_api_v1.syncSkillsRequest: description: Request to restore a project's installed skills to match its lock file @@ -3628,6 +3828,41 @@ components: result: $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_workloads_upgrade.CheckResult' type: object + pkg_api_v1.upgradePluginsRequest: + description: Request to re-resolve a project's lock entries and install newer + content + properties: + allow_ref_change: + description: AllowRefChange permits resolvedReference changes during upgrade + type: boolean + clients: + description: |- + Clients lists target client identifiers. Empty means every + plugin-supporting client detected on this host. + items: + type: string + type: array + uniqueItems: false + fail_on_changes: + description: FailOnChanges exits with an error when any mutable source would + upgrade + type: boolean + names: + description: Names restricts the upgrade to specific plugin names. Empty + means every entry. + items: + type: string + type: array + uniqueItems: false + preview: + description: Preview reports what would change without installing (still + fetches to compare digests) + type: boolean + project_root: + description: ProjectRoot is the project root path whose lock file should + be upgraded + type: string + type: object pkg_api_v1.upgradeRequest: description: Request to apply an available upgrade to a workload. All fields are optional; an empty body applies the upgrade preserving the workload's @@ -4079,7 +4314,20 @@ components: type: string type: object registry.Provenance: - description: Provenance contains verification and signing metadata + description: |- + Provenance is the expected signer identity for this skill, checked on + first install instead of trust-on-first-use. Absent means unconstrained + — most catalog entries won't have this for a while, and that must not + break installs; it's an opt-in tightening per entry, not a requirement. + + Each field constrains independently, and an empty string leaves that + dimension unconstrained. Attestation is the exception: setting it at + all, even to an empty struct, requires the artifact to be attested, so + verification fails against a signature carrying no statement. Its own + PredicateType and Predicate then follow the usual rule and constrain + only when set. Predicate must be a JSON object; anything else can never + match, and Validate rejects it rather than letting it through as a + constraint that silently fails every artifact. properties: attestation: $ref: '#/components/schemas/registry.VerifiedAttestation' @@ -4269,6 +4517,8 @@ components: $ref: '#/components/schemas/registry.SkillPackage' type: array uniqueItems: false + provenance: + $ref: '#/components/schemas/registry.Provenance' repository: $ref: '#/components/schemas/registry.SkillRepository' status: @@ -4573,7 +4823,10 @@ components: enablePrometheusMetricsPath: description: |- EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint. - The metrics are served on the main transport port at /metrics. + The metrics are served at /metrics on a dedicated diagnostics port rather than on the + main transport port, so the endpoint can be restricted by port and is not routed + alongside application traffic. The endpoint is unauthenticated either way. + See PrometheusPort and pkg/diagnostics. This is separate from OTLP metrics which are sent to the Endpoint. +kubebuilder:default=false +optional @@ -4615,6 +4868,32 @@ components: +kubebuilder:default=false +optional type: boolean + metricsOnTransportPort: + description: |- + MetricsOnTransportPort controls whether /metrics is ALSO served on the main + transport port, in addition to the diagnostics port. It exists to give + deployments a migration window: while true, an existing scrape configuration + aimed at the transport port keeps working, and a new one aimed at + PrometheusPort works too, so a scraper can be moved and verified before the + old location goes away. See https://github.com/stacklok/toolhive/issues/6384 for + the removal timeline. + + +optional + type: boolean + prometheusPort: + description: |- + PrometheusPort is the port the Prometheus /metrics endpoint is served on when + EnablePrometheusMetricsPath is true. It is deliberately not the main transport port, + so that access can be restricted with a NetworkPolicy: NetworkPolicy matches on port, + not on HTTP path, so a shared port makes "allow MCP traffic, deny metrics scraping" + impossible to express. The endpoint itself is unauthenticated, so restricting who can + reach this port is how it is protected. + + Zero selects the default diagnostics port (9464, the OpenTelemetry specification's + Prometheus exporter default). If that port is taken the listener falls back to an + available one and logs the resolved address. Do not route this port publicly. + +optional + type: integer samplingRate: description: |- SamplingRate is the trace sampling rate (0.0-1.0) as a string. @@ -5553,6 +5832,109 @@ paths: summary: Push a plugin tags: - plugins + /api/v1beta/plugins/sync: + post: + description: Restore a project's installed plugins to match toolhive.lock.yaml + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.syncPluginsRequest' + description: Sync request + summary: request + description: Sync request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.SyncResult' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "403": + content: + application/json: + schema: + type: string + description: Forbidden (feature not enabled) + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "501": + content: + application/json: + schema: + type: string + description: Not Implemented + summary: Sync project plugins from the lock file + tags: + - plugins + /api/v1beta/plugins/upgrade: + post: + description: Re-resolve a project's lock entries and install newer content where + available + requestBody: + content: + application/json: + schema: + oneOf: + - type: object + - $ref: '#/components/schemas/pkg_api_v1.upgradePluginsRequest' + description: Upgrade request + summary: request + description: Upgrade request + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/github_com_stacklok_toolhive_pkg_plugins.UpgradeResult' + description: OK + "400": + content: + application/json: + schema: + type: string + description: Bad Request + "403": + content: + application/json: + schema: + type: string + description: Forbidden (feature not enabled) + "404": + content: + application/json: + schema: + type: string + description: Not Found (a requested name is not in the lock file) + "500": + content: + application/json: + schema: + type: string + description: Internal Server Error + "501": + content: + application/json: + schema: + type: string + description: Not Implemented + summary: Upgrade project plugins + tags: + - plugins /api/v1beta/plugins/validate: post: description: Validate a plugin definition @@ -6728,7 +7110,11 @@ paths: tags: - workloads post: - description: Create and start a new workload + description: |- + Create and start a new workload + runtime_config is only accepted for protocol-scheme images + (uvx://, npx://, go://); supplying it with an ordinary image + reference or a remote url is rejected with 400. requestBody: content: application/json: @@ -6823,7 +7209,12 @@ paths: - workloads /api/v1beta/workloads/{name}/edit: post: - description: Update an existing workload configuration + description: |- + Update an existing workload configuration + runtime_config on a non-protocol-scheme image is accepted only when it + exactly matches the workload's persisted config and the image and url + are unchanged (an inert echo, e.g. from a prior GET); otherwise it is + rejected with 400. parameters: - description: Workload name in: path diff --git a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json index c8a44e5d..eb434ae8 100644 --- a/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcpexternalauthconfigs.schema.json @@ -132,6 +132,11 @@ "description": "AllowConfidentialClientRegistration permits RFC 7591 Dynamic Client\nRegistration of confidential clients: when true, /oauth/register\naccepts token_endpoint_auth_method values client_secret_basic and\nclient_secret_post in addition to \"none\" (still the default on\nomission) and mints a client_secret returned exactly once.\nConfidential registrations are restricted to https non-loopback\nredirect URIs, and on the Redis storage backend all DCR-issued\nregistrations are evicted after 30 days of inactivity and must\nre-register. This gates registration only: disabling it does not\nrevoke or reject already-minted secrets at the token endpoint.\n\nSecurity: registration is unauthenticated, so enabling this lets any\ncaller who can reach the endpoint obtain a client credential.\nCombining it with insecureAllowHTTP is rejected at validation.", "type": "boolean" }, + "allowPrivateKeyJWTRegistration": { + "default": false, + "description": "AllowPrivateKeyJWTRegistration permits Dynamic Client Registration of\nclients using private_key_jwt authentication. Registration behavior is\nintentionally configured separately from confidential-client registration.\n\nSecurity: registration is unauthenticated, so enabling this lets any\ncaller who can reach the endpoint register a private_key_jwt client.\nUnlike allowConfidentialClientRegistration, this is NOT rejected when\ncombined with insecureAllowHTTP: registration never returns a secret\nfor a private_key_jwt client, so there is nothing for cleartext HTTP\nto expose.", + "type": "boolean" + }, "authorizationEndpointBaseUrl": { "description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n`{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`.\nAll other endpoints (token, registration, JWKS) remain derived from the issuer.\nThis is useful when the browser-facing authorization endpoint needs to be on a\ndifferent host than the issuer used for backend-to-backend calls.\nMust be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts\nwhen insecureAllowHTTP is true) without query, fragment, or trailing slash.", "pattern": "^https?://[^\\s?#]+[^/\\s?#]$", @@ -284,7 +289,7 @@ }, "insecureAllowConfidentialOverLoopbackHTTP": { "default": false, - "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to\nallowConfidentialClientRegistration when issuer is a plain-HTTP loopback\nURL (e.g. \"http://localhost:8080\"). Without this flag, that combination\nis rejected at reconcile time: a loopback http:// issuer is normally\nfine for local development since the traffic never leaves the machine,\nbut combined with confidential registration it means /oauth/register —\nwhich is unauthenticated — mints client secrets over cleartext. Forcing\nTLS onto every loopback deployment instead would just push operators\ntoward insecureAllowHTTP, which is worse: that also disables the\nnon-loopback host check. Has no effect when\nallowConfidentialClientRegistration is false or issuer is https.", + "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential\nDynamic Client Registration (DCR) and delegate clients when issuer is a\nplain-HTTP loopback URL (e.g. \"http://localhost:8080\"). Without this\nflag, that combination is rejected at reconcile time: a loopback http://\nissuer is normally fine for local development since the traffic never\nleaves the machine, but confidential clients send secrets over cleartext.\nForcing TLS onto every loopback deployment instead would just push\noperators toward insecureAllowHTTP, which is worse: that also disables\nthe non-loopback host check. Has no effect when there are no confidential\nclients or issuer is https.\n\nprivate_key_jwt registration has no equivalent flag or transport\nrestriction: unlike confidential registration, it never returns a\nclient_secret (or any other secret) in the DCR response, so there is\nnothing here for cleartext HTTP to expose.", "type": "boolean" }, "insecureAllowHTTP": { @@ -563,16 +568,21 @@ "trustedIssuers": { "description": "TrustedIssuers configures external OIDC issuers whose tokens are\naccepted as RFC 8693 subject tokens during token exchange, in addition\nto self-issued subject tokens. Empty (the default) means only\nself-issued subject tokens are accepted. See\ndocs/arch/17-token-exchange-delegation.md for the trust model.", "items": { - "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens during token exchange. It mirrors\ntokenexchange.TrustedIssuer (pkg/authserver/server/tokenexchange), the\nruntime type the operator converts this into directly — no secret is\nreferenced by this type, so no SecretKeyRef indirection is needed, unlike\nDelegateClientConfig.", + "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during\ntoken exchange. It mirrors tokenexchange.TrustedIssuer\n(pkg/authserver/server/tokenexchange), the runtime type the operator converts\nthis into directly — no secret is referenced by this type, so no SecretKeyRef\nindirection is needed, unlike DelegateClientConfig.\n\nexpectedAudience is exempted only for a grant-only issuer: jwtBearerGrant\npresent and none of actorClaim, actorMatcher, allowMayAct, or allowedActors\nset. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct,\nallowedActors) still requires expectedAudience, even when combined with\njwtBearerGrant.\n\nThe allowedDelegateClients rule below mirrors validateDelegationPolicy\n(pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is\nkeyed on whether ANY delegation field is set (expectedAudience,\nactorClaim, actorMatcher, allowMayAct), not merely on whether\njwtBearerGrant is absent — an issuer can combine jwtBearerGrant with\nexpectedAudience for RFC 8693 delegation on the same issuer, and that\ncombination still requires allowedDelegateClients at the Go level.", "properties": { "actorClaim": { "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from this external issuer (used by allowedActors below).\nDefaults to \"azp\" when empty; use \"appid\" for Microsoft Entra v1, \"cid\"\nfor Okta. The special value \"client_id\" reads the subject token's\nclient_id claim instead.", "maxLength": 64, "type": "string" }, + "actorMatcher": { + "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\nsubject token's complete signature-verified claims map (bound as\n\"claims\") to authorize a class of external actors, in addition to (not\ninstead of) allowedActors — either signal is sufficient. Must evaluate\nto a boolean; a non-boolean result denies the token at evaluation time,\nnot at reconcile time. A syntactically invalid expression fails\nreconciliation (surfaced via the AuthServerConfigValidated condition),\nnot admission — there is no validating webhook for this field.", + "maxLength": 4096, + "type": "string" + }, "allowMayAct": { "default": false, - "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors. Does not affect\nself-issued subject tokens. The wildcard is never permitted alongside\nspecific allowedDelegateClients, regardless of this setting.", + "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors and actorMatcher.\nDoes not affect self-issued subject tokens. The wildcard is never\npermitted alongside specific allowedDelegateClients, regardless of\nthis setting.", "type": "boolean" }, "allowPrivateIPs": { @@ -580,7 +590,7 @@ "type": "boolean" }, "allowedActors": { - "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim. Empty denies every token unless allowMayAct is true\nand the token carries a permitted may_act claim.", + "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim, in addition to (not instead of) actorMatcher below —\neither signal is sufficient. Empty denies every token unless\nactorMatcher is set, or allowMayAct is true and the token carries a\npermitted may_act claim.", "items": { "maxLength": 256, "minLength": 1, @@ -591,7 +601,7 @@ "x-kubernetes-list-type": "atomic" }, "allowedDelegateClients": { - "description": "AllowedDelegateClients restricts which ToolHive client IDs may\nexchange a subject token from this issuer. Required; set it to [\"*\"]\nto permit any confidential client holding the token-exchange grant. The\nwildcard must be the only entry; otherwise list specific client IDs to\nbind delegation to them.", + "description": "AllowedDelegateClients restricts which ToolHive client IDs may exchange\nan RFC 8693 subject token from this issuer. Required unless only\njwtBearerGrant is configured; set it to [\"*\"] to permit any confidential\nclient holding the token-exchange grant, or list specific client IDs to\nbind delegation to them.", "items": { "maxLength": 256, "minLength": 1, @@ -603,7 +613,7 @@ "x-kubernetes-list-type": "atomic" }, "expectedAudience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nthe token's audience list. This should be a resource/API identifier\n(e.g. a URI), not a client ID.", + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nan RFC 8693 subject token's audience list. It is not used by an RFC 7523\nJWT-bearer assertion, whose audience is the token endpoint.", "maxLength": 2048, "minLength": 1, "type": "string" @@ -622,18 +632,89 @@ "description": "JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. If\nempty, it is resolved via OIDC discovery at\n{issuerUrl}/.well-known/openid-configuration.", "maxLength": 2048, "type": "string" + }, + "jwtBearerGrant": { + "description": "JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this\nissuer. It is independent of RFC 8693 delegation policy.", + "properties": { + "acceptedAudiences": { + "description": "AcceptedAudiences identifies this authorization server's accepted\nassertion audiences. When omitted, runtime validation defaults to the\ntoken endpoint.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "maxAssertionAge": { + "description": "MaxAssertionAge caps the exp-iat interval independently of exp.", + "type": "string" + }, + "subjectBindings": { + "description": "SubjectBindings maps an exact external subject to allowed RFC 8707\nresources.", + "items": { + "description": "JWTBearerSubjectBinding configures the exact subject and allowed resources\nfor one RFC 7523 JWT-bearer assertion identity.", + "properties": { + "allowedResources": { + "description": "AllowedResources is the exact set of RFC 8707 resources this subject may\nrequest.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "subject": { + "description": "Subject is an exact assertion sub value. Wildcards are not supported.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[^*]+$", + "type": "string" + } + }, + "required": [ + "allowedResources", + "subject" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "maxAssertionAge", + "subjectBindings" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "maxAssertionAge must be greater than zero", + "rule": "duration(self.maxAssertionAge) > duration('0s')" + }, + { + "message": "subjectBindings must not contain duplicate subjects", + "rule": "self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1)" + } + ] } }, "required": [ - "allowedDelegateClients", - "expectedAudience", "issuerUrl" ], "type": "object", "x-kubernetes-validations": [ { "message": "allowedDelegateClients must not combine the wildcard \"*\" with specific client IDs", - "rule": "!('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1" + "rule": "!has(self.allowedDelegateClients) || !('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1" }, { "message": "allowMayAct must not be enabled when allowedDelegateClients contains the wildcard \"*\"", @@ -646,6 +727,14 @@ { "message": "allowPrivateIPs requires jwksUrl to be set explicitly", "rule": "!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != \"\")" + }, + { + "message": "expectedAudience is required unless jwtBearerGrant is configured without actorClaim, actorMatcher, allowMayAct, or allowedActors", + "rule": "(has(self.jwtBearerGrant) && !((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)" + }, + { + "message": "allowedDelegateClients is required when expectedAudience, actorClaim, actorMatcher, or allowMayAct is set", + "rule": "!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)" } ] }, @@ -1070,13 +1159,13 @@ "message": "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint", "rule": "!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)" }, - { - "message": "delegateClients require an https:// issuer; delegate client secrets must not be sent over plaintext HTTP", - "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://')" - }, { "message": "forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true", "rule": "(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)" + }, + { + "message": "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback", + "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)" } ] }, diff --git a/static/api-specs/toolhive-crds/mcpremoteproxies.schema.json b/static/api-specs/toolhive-crds/mcpremoteproxies.schema.json index 040c7ac4..5a682867 100644 --- a/static/api-specs/toolhive-crds/mcpremoteproxies.schema.json +++ b/static/api-specs/toolhive-crds/mcpremoteproxies.schema.json @@ -15,6 +15,11 @@ "spec": { "description": "MCPRemoteProxySpec defines the desired state of MCPRemoteProxy", "properties": { + "allowPrivateEndpoint": { + "default": false, + "description": "AllowPrivateEndpoint permits RemoteURL to point at a private or\nin-cluster endpoint: RFC 1918 and IPv6 unique-local addresses, and\nhostnames ending in \"cluster.local\". (Bare \".svc\" hostnames are never\nblocked, since no DNS resolution is performed.) Enable this to reach a\nco-located in-cluster backend in-mesh so the backend's workload-identity\nauthorization policy still applies. Loopback, link-local, cloud-metadata,\nand kubernetes.default endpoints remain blocked regardless of this\nsetting.", + "type": "boolean" + }, "audit": { "description": "Audit defines audit logging configuration for the proxy", "properties": { @@ -303,6 +308,761 @@ "proxyDeployment": { "description": "ProxyDeployment defines overrides for the Proxy Deployment resource (toolhive proxy)", "properties": { + "affinity": { + "description": "Affinity sets node/pod affinity and anti-affinity for the proxy pod.\nOn MCPRemoteProxy, spec.podTemplateSpec also reaches the proxy pod: the two\nare merged per sub-field, and podTemplateSpec wins on sub-fields set in both.", + "properties": { + "nodeAffinity": { + "description": "Describes node affinity scheduling rules for the pod.", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "description": "The scheduler will prefer to schedule pods to nodes that satisfy\nthe affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n\"weight\" to the sum if the node matches the corresponding matchExpressions; the\nnode(s) with the highest sum are the most preferred.", + "items": { + "description": "An empty preferred scheduling term matches all objects with implicit weight 0\n(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).", + "properties": { + "preference": { + "description": "A node selector term, associated with the corresponding weight.", + "properties": { + "matchExpressions": { + "description": "A list of node selector requirements by node's labels.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchFields": { + "description": "A list of node selector requirements by node's fields.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "weight": { + "description": "Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "preference", + "weight" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "description": "If the affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to an update), the system\nmay or may not try to eventually evict the pod from its node.", + "properties": { + "nodeSelectorTerms": { + "description": "Required. A list of node selector terms. The terms are ORed.", + "items": { + "description": "A null or empty node selector term matches no objects. The requirements of\nthem are ANDed.\nThe TopologySelectorTerm type implements a subset of the NodeSelectorTerm.", + "properties": { + "matchExpressions": { + "description": "A list of node selector requirements by node's labels.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchFields": { + "description": "A list of node selector requirements by node's fields.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "nodeSelectorTerms" + ], + "type": "object", + "x-kubernetes-map-type": "atomic" + } + }, + "type": "object" + }, + "podAffinity": { + "description": "Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "description": "The scheduler will prefer to schedule pods to nodes that satisfy\nthe affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n\"weight\" to the sum if the node has pods which matches the corresponding podAffinityTerm; the\nnode(s) with the highest sum are the most preferred.", + "items": { + "description": "The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)", + "properties": { + "podAffinityTerm": { + "description": "Required. A pod affinity term, associated with the corresponding weight.", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "weight": { + "description": "weight associated with matching the corresponding podAffinityTerm,\nin the range 1-100.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "podAffinityTerm", + "weight" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "description": "If the affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to a pod label update), the\nsystem may or may not try to eventually evict the pod from its node.\nWhen there are multiple elements, the lists of nodes corresponding to each\npodAffinityTerm are intersected, i.e. all terms must be satisfied.", + "items": { + "description": "Defines a set of pods (namely those matching the labelSelector\nrelative to the given namespace(s)) that this pod should be\nco-located (affinity) or not co-located (anti-affinity) with,\nwhere co-located is defined as running on a node whose value of\nthe label with key matches that of any node on which\na pod of the set of pods is running", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + }, + "podAntiAffinity": { + "description": "Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "description": "The scheduler will prefer to schedule pods to nodes that satisfy\nthe anti-affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling anti-affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and subtracting\n\"weight\" from the sum if the node has pods which matches the corresponding podAffinityTerm; the\nnode(s) with the highest sum are the most preferred.", + "items": { + "description": "The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)", + "properties": { + "podAffinityTerm": { + "description": "Required. A pod affinity term, associated with the corresponding weight.", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "weight": { + "description": "weight associated with matching the corresponding podAffinityTerm,\nin the range 1-100.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "podAffinityTerm", + "weight" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "description": "If the anti-affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the anti-affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to a pod label update), the\nsystem may or may not try to eventually evict the pod from its node.\nWhen there are multiple elements, the lists of nodes corresponding to each\npodAffinityTerm are intersected, i.e. all terms must be satisfied.", + "items": { + "description": "Defines a set of pods (namely those matching the labelSelector\nrelative to the given namespace(s)) that this pod should be\nco-located (affinity) or not co-located (anti-affinity) with,\nwhere co-located is defined as running on a node whose value of\nthe label with key matches that of any node on which\na pod of the set of pods is running", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + } + }, + "type": "object" + }, "annotations": { "additionalProperties": { "type": "string" @@ -360,6 +1120,13 @@ "description": "Labels to add or override on the resource", "type": "object" }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "description": "NodeSelector constrains the proxy pod to nodes with matching labels.\nMirrors the scheduling control podTemplateSpec gives the MCP server pod, so\nthe proxy can be steered onto the same nodes (e.g. a pre-warmed pool).\nOn MCPRemoteProxy, spec.podTemplateSpec also reaches the proxy pod: the two\nmaps are merged, and podTemplateSpec wins on keys set in both.", + "type": "object" + }, "podTemplateMetadataOverrides": { "description": "ResourceMetadataOverrides defines metadata overrides for a resource", "properties": { @@ -379,6 +1146,38 @@ } }, "type": "object" + }, + "tolerations": { + "description": "Tolerations allow the proxy pod to schedule onto tainted nodes, such as a\ndedicated pre-warmed pool.\nOn MCPRemoteProxy, spec.podTemplateSpec also reaches the proxy pod, and this\nlist is atomic: a podTemplateSpec that sets tolerations replaces this field\nrather than adding to it.", + "items": { + "description": "The pod this Toleration is attached to tolerates any taint that matches\nthe triple using the matching operator .", + "properties": { + "effect": { + "description": "Effect indicates the taint effect to match. Empty means match all taint effects.\nWhen specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute.", + "type": "string" + }, + "key": { + "description": "Key is the taint key that the toleration applies to. Empty means match all taint keys.\nIf the key is empty, operator must be Exists; this combination means to match all values and all keys.", + "type": "string" + }, + "operator": { + "description": "Operator represents a key's relationship to the value.\nValid operators are Exists, Equal, Lt, and Gt. Defaults to Equal.\nExists is equivalent to wildcard for value, so that a pod can\ntolerate all taints of a particular category.\nLt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators).", + "type": "string" + }, + "tolerationSeconds": { + "description": "TolerationSeconds represents the period of time the toleration (which must be\nof effect NoExecute, otherwise this field is ignored) tolerates the taint. By default,\nit is not set, which means tolerate the taint forever (do not evict). Zero and\nnegative values will be treated as 0 (evict immediately) by the system.", + "format": "int64", + "type": "integer" + }, + "value": { + "description": "Value is the taint value the toleration matches to.\nIf the operator is Exists, the value should be empty, otherwise just a regular string.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" } }, "type": "object" diff --git a/static/api-specs/toolhive-crds/mcpserverentries.schema.json b/static/api-specs/toolhive-crds/mcpserverentries.schema.json index ed37c587..76f00949 100644 --- a/static/api-specs/toolhive-crds/mcpserverentries.schema.json +++ b/static/api-specs/toolhive-crds/mcpserverentries.schema.json @@ -14,6 +14,11 @@ "spec": { "description": "MCPServerEntrySpec defines the desired state of MCPServerEntry.\nMCPServerEntry is a zero-infrastructure catalog entry that declares a remote MCP\nserver endpoint. Unlike MCPRemoteProxy, it creates no pods, services, or deployments.", "properties": { + "allowPrivateEndpoint": { + "default": false, + "description": "AllowPrivateEndpoint permits RemoteURL to point at a private or\nin-cluster endpoint: RFC 1918 and IPv6 unique-local addresses, and\nhostnames ending in \"cluster.local\". (Bare \".svc\" hostnames are never\nblocked, since no DNS resolution is performed.) Enable this to reach a\nco-located in-cluster backend in-mesh so the backend's workload-identity\nauthorization policy still applies. Loopback, link-local, cloud-metadata,\nand kubernetes.default endpoints remain blocked regardless of this\nsetting.", + "type": "boolean" + }, "caBundleRef": { "description": "CABundleRef references a ConfigMap containing CA certificates for TLS verification\nwhen connecting to the remote MCP server.", "properties": { diff --git a/static/api-specs/toolhive-crds/mcpservers.schema.json b/static/api-specs/toolhive-crds/mcpservers.schema.json index 2d83d1dc..d749959c 100644 --- a/static/api-specs/toolhive-crds/mcpservers.schema.json +++ b/static/api-specs/toolhive-crds/mcpservers.schema.json @@ -450,6 +450,761 @@ "proxyDeployment": { "description": "ProxyDeployment defines overrides for the Proxy Deployment resource (toolhive proxy)", "properties": { + "affinity": { + "description": "Affinity sets node/pod affinity and anti-affinity for the proxy pod.\nOn MCPRemoteProxy, spec.podTemplateSpec also reaches the proxy pod: the two\nare merged per sub-field, and podTemplateSpec wins on sub-fields set in both.", + "properties": { + "nodeAffinity": { + "description": "Describes node affinity scheduling rules for the pod.", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "description": "The scheduler will prefer to schedule pods to nodes that satisfy\nthe affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n\"weight\" to the sum if the node matches the corresponding matchExpressions; the\nnode(s) with the highest sum are the most preferred.", + "items": { + "description": "An empty preferred scheduling term matches all objects with implicit weight 0\n(i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op).", + "properties": { + "preference": { + "description": "A node selector term, associated with the corresponding weight.", + "properties": { + "matchExpressions": { + "description": "A list of node selector requirements by node's labels.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchFields": { + "description": "A list of node selector requirements by node's fields.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "weight": { + "description": "Weight associated with matching the corresponding nodeSelectorTerm, in the range 1-100.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "preference", + "weight" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "description": "If the affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to an update), the system\nmay or may not try to eventually evict the pod from its node.", + "properties": { + "nodeSelectorTerms": { + "description": "Required. A list of node selector terms. The terms are ORed.", + "items": { + "description": "A null or empty node selector term matches no objects. The requirements of\nthem are ANDed.\nThe TopologySelectorTerm type implements a subset of the NodeSelectorTerm.", + "properties": { + "matchExpressions": { + "description": "A list of node selector requirements by node's labels.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchFields": { + "description": "A list of node selector requirements by node's fields.", + "items": { + "description": "A node selector requirement is a selector that contains values, a key, and an operator\nthat relates the key and values.", + "properties": { + "key": { + "description": "The label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "Represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt.", + "type": "string" + }, + "values": { + "description": "An array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. If the operator is Gt or Lt, the values\narray must have a single element, which will be interpreted as an integer.\nThis array is replaced during a strategic merge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "nodeSelectorTerms" + ], + "type": "object", + "x-kubernetes-map-type": "atomic" + } + }, + "type": "object" + }, + "podAffinity": { + "description": "Describes pod affinity scheduling rules (e.g. co-locate this pod in the same node, zone, etc. as some other pod(s)).", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "description": "The scheduler will prefer to schedule pods to nodes that satisfy\nthe affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and adding\n\"weight\" to the sum if the node has pods which matches the corresponding podAffinityTerm; the\nnode(s) with the highest sum are the most preferred.", + "items": { + "description": "The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)", + "properties": { + "podAffinityTerm": { + "description": "Required. A pod affinity term, associated with the corresponding weight.", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "weight": { + "description": "weight associated with matching the corresponding podAffinityTerm,\nin the range 1-100.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "podAffinityTerm", + "weight" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "description": "If the affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to a pod label update), the\nsystem may or may not try to eventually evict the pod from its node.\nWhen there are multiple elements, the lists of nodes corresponding to each\npodAffinityTerm are intersected, i.e. all terms must be satisfied.", + "items": { + "description": "Defines a set of pods (namely those matching the labelSelector\nrelative to the given namespace(s)) that this pod should be\nco-located (affinity) or not co-located (anti-affinity) with,\nwhere co-located is defined as running on a node whose value of\nthe label with key matches that of any node on which\na pod of the set of pods is running", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + }, + "podAntiAffinity": { + "description": "Describes pod anti-affinity scheduling rules (e.g. avoid putting this pod in the same node, zone, etc. as some other pod(s)).", + "properties": { + "preferredDuringSchedulingIgnoredDuringExecution": { + "description": "The scheduler will prefer to schedule pods to nodes that satisfy\nthe anti-affinity expressions specified by this field, but it may choose\na node that violates one or more of the expressions. The node that is\nmost preferred is the one with the greatest sum of weights, i.e.\nfor each node that meets all of the scheduling requirements (resource\nrequest, requiredDuringScheduling anti-affinity expressions, etc.),\ncompute a sum by iterating through the elements of this field and subtracting\n\"weight\" from the sum if the node has pods which matches the corresponding podAffinityTerm; the\nnode(s) with the highest sum are the most preferred.", + "items": { + "description": "The weights of all of the matched WeightedPodAffinityTerm fields are added per-node to find the most preferred node(s)", + "properties": { + "podAffinityTerm": { + "description": "Required. A pod affinity term, associated with the corresponding weight.", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "weight": { + "description": "weight associated with matching the corresponding podAffinityTerm,\nin the range 1-100.", + "format": "int32", + "type": "integer" + } + }, + "required": [ + "podAffinityTerm", + "weight" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "requiredDuringSchedulingIgnoredDuringExecution": { + "description": "If the anti-affinity requirements specified by this field are not met at\nscheduling time, the pod will not be scheduled onto the node.\nIf the anti-affinity requirements specified by this field cease to be met\nat some point during pod execution (e.g. due to a pod label update), the\nsystem may or may not try to eventually evict the pod from its node.\nWhen there are multiple elements, the lists of nodes corresponding to each\npodAffinityTerm are intersected, i.e. all terms must be satisfied.", + "items": { + "description": "Defines a set of pods (namely those matching the labelSelector\nrelative to the given namespace(s)) that this pod should be\nco-located (affinity) or not co-located (anti-affinity) with,\nwhere co-located is defined as running on a node whose value of\nthe label with key matches that of any node on which\na pod of the set of pods is running", + "properties": { + "labelSelector": { + "description": "A label query over a set of resources, in this case pods.\nIf it's null, this PodAffinityTerm matches with no Pods.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "matchLabelKeys": { + "description": "MatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both matchLabelKeys and labelSelector.\nAlso, matchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "mismatchLabelKeys": { + "description": "MismatchLabelKeys is a set of pod label keys to select which pods will\nbe taken into consideration. The keys are used to lookup values from the\nincoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)`\nto select the group of existing pods which pods will be taken into consideration\nfor the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming\npod labels will be ignored. The default value is empty.\nThe same key is forbidden to exist in both mismatchLabelKeys and labelSelector.\nAlso, mismatchLabelKeys cannot be set when labelSelector isn't set.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "namespaceSelector": { + "description": "A label query over the set of namespaces that the term applies to.\nThe term is applied to the union of the namespaces selected by this field\nand the ones listed in the namespaces field.\nnull selector and null or empty namespaces list means \"this pod's namespace\".\nAn empty selector ({}) matches all namespaces.", + "properties": { + "matchExpressions": { + "description": "matchExpressions is a list of label selector requirements. The requirements are ANDed.", + "items": { + "description": "A label selector requirement is a selector that contains values, a key, and an operator that\nrelates the key and values.", + "properties": { + "key": { + "description": "key is the label key that the selector applies to.", + "type": "string" + }, + "operator": { + "description": "operator represents a key's relationship to a set of values.\nValid operators are In, NotIn, Exists and DoesNotExist.", + "type": "string" + }, + "values": { + "description": "values is an array of string values. If the operator is In or NotIn,\nthe values array must be non-empty. If the operator is Exists or DoesNotExist,\nthe values array must be empty. This array is replaced during a strategic\nmerge patch.", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "key", + "operator" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "matchLabels": { + "additionalProperties": { + "type": "string" + }, + "description": "matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels\nmap is equivalent to an element of matchExpressions, whose key field is \"key\", the\noperator is \"In\", and the values array contains only \"value\". The requirements are ANDed.", + "type": "object" + } + }, + "type": "object", + "x-kubernetes-map-type": "atomic" + }, + "namespaces": { + "description": "namespaces specifies a static list of namespace names that the term applies to.\nThe term is applied to the union of the namespaces listed in this field\nand the ones selected by namespaceSelector.\nnull or empty namespaces list and null namespaceSelector means \"this pod's namespace\".", + "items": { + "type": "string" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "topologyKey": { + "description": "This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching\nthe labelSelector in the specified namespaces, where co-located is defined as running on a node\nwhose value of the label with key topologyKey matches that of any node on which any of the\nselected pods is running.\nEmpty topologyKey is not allowed.", + "type": "string" + } + }, + "required": [ + "topologyKey" + ], + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "type": "object" + } + }, + "type": "object" + }, "annotations": { "additionalProperties": { "type": "string" @@ -507,6 +1262,13 @@ "description": "Labels to add or override on the resource", "type": "object" }, + "nodeSelector": { + "additionalProperties": { + "type": "string" + }, + "description": "NodeSelector constrains the proxy pod to nodes with matching labels.\nMirrors the scheduling control podTemplateSpec gives the MCP server pod, so\nthe proxy can be steered onto the same nodes (e.g. a pre-warmed pool).\nOn MCPRemoteProxy, spec.podTemplateSpec also reaches the proxy pod: the two\nmaps are merged, and podTemplateSpec wins on keys set in both.", + "type": "object" + }, "podTemplateMetadataOverrides": { "description": "ResourceMetadataOverrides defines metadata overrides for a resource", "properties": { @@ -526,6 +1288,38 @@ } }, "type": "object" + }, + "tolerations": { + "description": "Tolerations allow the proxy pod to schedule onto tainted nodes, such as a\ndedicated pre-warmed pool.\nOn MCPRemoteProxy, spec.podTemplateSpec also reaches the proxy pod, and this\nlist is atomic: a podTemplateSpec that sets tolerations replaces this field\nrather than adding to it.", + "items": { + "description": "The pod this Toleration is attached to tolerates any taint that matches\nthe triple using the matching operator .", + "properties": { + "effect": { + "description": "Effect indicates the taint effect to match. Empty means match all taint effects.\nWhen specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute.", + "type": "string" + }, + "key": { + "description": "Key is the taint key that the toleration applies to. Empty means match all taint keys.\nIf the key is empty, operator must be Exists; this combination means to match all values and all keys.", + "type": "string" + }, + "operator": { + "description": "Operator represents a key's relationship to the value.\nValid operators are Exists, Equal, Lt, and Gt. Defaults to Equal.\nExists is equivalent to wildcard for value, so that a pod can\ntolerate all taints of a particular category.\nLt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators).", + "type": "string" + }, + "tolerationSeconds": { + "description": "TolerationSeconds represents the period of time the toleration (which must be\nof effect NoExecute, otherwise this field is ignored) tolerates the taint. By default,\nit is not set, which means tolerate the taint forever (do not evict). Zero and\nnegative values will be treated as 0 (evict immediately) by the system.", + "format": "int64", + "type": "integer" + }, + "value": { + "description": "Value is the taint value the toleration matches to.\nIf the operator is Exists, the value should be empty, otherwise just a regular string.", + "type": "string" + } + }, + "type": "object" + }, + "type": "array", + "x-kubernetes-list-type": "atomic" } }, "type": "object" diff --git a/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json b/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json index 42815ff3..dd62ecc5 100644 --- a/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json +++ b/static/api-specs/toolhive-crds/mcptelemetryconfigs.schema.json @@ -164,6 +164,10 @@ "default": false, "description": "Enabled controls whether Prometheus metrics endpoint is exposed", "type": "boolean" + }, + "metricsOnTransportPort": { + "description": "MetricsOnTransportPort controls whether /metrics is ALSO served on the main\ntransport port, in addition to the dedicated diagnostics port. It exists to\ngive deployments a migration window: while true, an existing scrape\nconfiguration aimed at the transport port keeps working, so a scraper can be\nmoved to the diagnostics port and verified before the old location goes away.\n\nLeave unset to follow the current default, which is true during the migration\nwindow and becomes false when it closes. Setting it explicitly opts out of\nthat change: an explicit value is honoured before and after the cutover.\n\nSee https://github.com/stacklok/toolhive/issues/6384 for the removal timeline.", + "type": "boolean" } }, "type": "object" diff --git a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json index c106ef4f..9318f0da 100644 --- a/static/api-specs/toolhive-crds/virtualmcpservers.schema.json +++ b/static/api-specs/toolhive-crds/virtualmcpservers.schema.json @@ -23,6 +23,11 @@ "description": "AllowConfidentialClientRegistration permits RFC 7591 Dynamic Client\nRegistration of confidential clients: when true, /oauth/register\naccepts token_endpoint_auth_method values client_secret_basic and\nclient_secret_post in addition to \"none\" (still the default on\nomission) and mints a client_secret returned exactly once.\nConfidential registrations are restricted to https non-loopback\nredirect URIs, and on the Redis storage backend all DCR-issued\nregistrations are evicted after 30 days of inactivity and must\nre-register. This gates registration only: disabling it does not\nrevoke or reject already-minted secrets at the token endpoint.\n\nSecurity: registration is unauthenticated, so enabling this lets any\ncaller who can reach the endpoint obtain a client credential.\nCombining it with insecureAllowHTTP is rejected at validation.", "type": "boolean" }, + "allowPrivateKeyJWTRegistration": { + "default": false, + "description": "AllowPrivateKeyJWTRegistration permits Dynamic Client Registration of\nclients using private_key_jwt authentication. Registration behavior is\nintentionally configured separately from confidential-client registration.\n\nSecurity: registration is unauthenticated, so enabling this lets any\ncaller who can reach the endpoint register a private_key_jwt client.\nUnlike allowConfidentialClientRegistration, this is NOT rejected when\ncombined with insecureAllowHTTP: registration never returns a secret\nfor a private_key_jwt client, so there is nothing for cleartext HTTP\nto expose.", + "type": "boolean" + }, "authorizationEndpointBaseUrl": { "description": "AuthorizationEndpointBaseURL overrides the base URL used for the authorization_endpoint\nin the OAuth discovery document. When set, the discovery document will advertise\n`{authorizationEndpointBaseUrl}/oauth/authorize` instead of `{issuer}/oauth/authorize`.\nAll other endpoints (token, registration, JWKS) remain derived from the issuer.\nThis is useful when the browser-facing authorization endpoint needs to be on a\ndifferent host than the issuer used for backend-to-backend calls.\nMust be a valid HTTPS URL (or HTTP for localhost, or HTTP for trusted in-cluster hosts\nwhen insecureAllowHTTP is true) without query, fragment, or trailing slash.", "pattern": "^https?://[^\\s?#]+[^/\\s?#]$", @@ -175,7 +180,7 @@ }, "insecureAllowConfidentialOverLoopbackHTTP": { "default": false, - "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to\nallowConfidentialClientRegistration when issuer is a plain-HTTP loopback\nURL (e.g. \"http://localhost:8080\"). Without this flag, that combination\nis rejected at reconcile time: a loopback http:// issuer is normally\nfine for local development since the traffic never leaves the machine,\nbut combined with confidential registration it means /oauth/register —\nwhich is unauthenticated — mints client secrets over cleartext. Forcing\nTLS onto every loopback deployment instead would just push operators\ntoward insecureAllowHTTP, which is worse: that also disables the\nnon-loopback host check. Has no effect when\nallowConfidentialClientRegistration is false or issuer is https.", + "description": "InsecureAllowConfidentialOverLoopbackHTTP opts in to confidential\nDynamic Client Registration (DCR) and delegate clients when issuer is a\nplain-HTTP loopback URL (e.g. \"http://localhost:8080\"). Without this\nflag, that combination is rejected at reconcile time: a loopback http://\nissuer is normally fine for local development since the traffic never\nleaves the machine, but confidential clients send secrets over cleartext.\nForcing TLS onto every loopback deployment instead would just push\noperators toward insecureAllowHTTP, which is worse: that also disables\nthe non-loopback host check. Has no effect when there are no confidential\nclients or issuer is https.\n\nprivate_key_jwt registration has no equivalent flag or transport\nrestriction: unlike confidential registration, it never returns a\nclient_secret (or any other secret) in the DCR response, so there is\nnothing here for cleartext HTTP to expose.", "type": "boolean" }, "insecureAllowHTTP": { @@ -454,16 +459,21 @@ "trustedIssuers": { "description": "TrustedIssuers configures external OIDC issuers whose tokens are\naccepted as RFC 8693 subject tokens during token exchange, in addition\nto self-issued subject tokens. Empty (the default) means only\nself-issued subject tokens are accepted. See\ndocs/arch/17-token-exchange-delegation.md for the trust model.", "items": { - "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens during token exchange. It mirrors\ntokenexchange.TrustedIssuer (pkg/authserver/server/tokenexchange), the\nruntime type the operator converts this into directly — no secret is\nreferenced by this type, so no SecretKeyRef indirection is needed, unlike\nDelegateClientConfig.", + "description": "TrustedIssuerConfig configures an external OIDC issuer whose tokens are\naccepted as RFC 8693 subject tokens or RFC 7523 JWT-bearer assertions during\ntoken exchange. It mirrors tokenexchange.TrustedIssuer\n(pkg/authserver/server/tokenexchange), the runtime type the operator converts\nthis into directly — no secret is referenced by this type, so no SecretKeyRef\nindirection is needed, unlike DelegateClientConfig.\n\nexpectedAudience is exempted only for a grant-only issuer: jwtBearerGrant\npresent and none of actorClaim, actorMatcher, allowMayAct, or allowedActors\nset. Any RFC 8693 delegation field (actorClaim, actorMatcher, allowMayAct,\nallowedActors) still requires expectedAudience, even when combined with\njwtBearerGrant.\n\nThe allowedDelegateClients rule below mirrors validateDelegationPolicy\n(pkg/authserver/server/tokenexchange/multi_issuer_validator.go): it is\nkeyed on whether ANY delegation field is set (expectedAudience,\nactorClaim, actorMatcher, allowMayAct), not merely on whether\njwtBearerGrant is absent — an issuer can combine jwtBearerGrant with\nexpectedAudience for RFC 8693 delegation on the same issuer, and that\ncombination still requires allowedDelegateClients at the Go level.", "properties": { "actorClaim": { "description": "ActorClaim names the claim identifying the client that requested the\nsubject token from this external issuer (used by allowedActors below).\nDefaults to \"azp\" when empty; use \"appid\" for Microsoft Entra v1, \"cid\"\nfor Okta. The special value \"client_id\" reads the subject token's\nclient_id claim instead.", "maxLength": 64, "type": "string" }, + "actorMatcher": { + "description": "ActorMatcher is an admin-authored CEL expression evaluated against the\nsubject token's complete signature-verified claims map (bound as\n\"claims\") to authorize a class of external actors, in addition to (not\ninstead of) allowedActors — either signal is sufficient. Must evaluate\nto a boolean; a non-boolean result denies the token at evaluation time,\nnot at reconcile time. A syntactically invalid expression fails\nreconciliation (surfaced via the AuthServerConfigValidated condition),\nnot admission — there is no validating webhook for this field.", + "maxLength": 4096, + "type": "string" + }, "allowMayAct": { "default": false, - "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors. Does not affect\nself-issued subject tokens. The wildcard is never permitted alongside\nspecific allowedDelegateClients, regardless of this setting.", + "description": "AllowMayAct permits this external issuer's may_act claim to authorize\ndelegation. Defaults to false; external issuers must be opted in\nexplicitly because may_act bypasses allowedActors and actorMatcher.\nDoes not affect self-issued subject tokens. The wildcard is never\npermitted alongside specific allowedDelegateClients, regardless of\nthis setting.", "type": "boolean" }, "allowPrivateIPs": { @@ -471,7 +481,7 @@ "type": "boolean" }, "allowedActors": { - "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim. Empty denies every token unless allowMayAct is true\nand the token carries a permitted may_act claim.", + "description": "AllowedActors is the allowlist of actorClaim values authorized to\nexchange a subject token from this issuer when it carries no\n\"may_act\" claim, in addition to (not instead of) actorMatcher below —\neither signal is sufficient. Empty denies every token unless\nactorMatcher is set, or allowMayAct is true and the token carries a\npermitted may_act claim.", "items": { "maxLength": 256, "minLength": 1, @@ -482,7 +492,7 @@ "x-kubernetes-list-type": "atomic" }, "allowedDelegateClients": { - "description": "AllowedDelegateClients restricts which ToolHive client IDs may\nexchange a subject token from this issuer. Required; set it to [\"*\"]\nto permit any confidential client holding the token-exchange grant. The\nwildcard must be the only entry; otherwise list specific client IDs to\nbind delegation to them.", + "description": "AllowedDelegateClients restricts which ToolHive client IDs may exchange\nan RFC 8693 subject token from this issuer. Required unless only\njwtBearerGrant is configured; set it to [\"*\"] to permit any confidential\nclient holding the token-exchange grant, or list specific client IDs to\nbind delegation to them.", "items": { "maxLength": 256, "minLength": 1, @@ -494,7 +504,7 @@ "x-kubernetes-list-type": "atomic" }, "expectedAudience": { - "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nthe token's audience list. This should be a resource/API identifier\n(e.g. a URI), not a client ID.", + "description": "ExpectedAudience is the expected \"aud\" claim value that must appear in\nan RFC 8693 subject token's audience list. It is not used by an RFC 7523\nJWT-bearer assertion, whose audience is the token endpoint.", "maxLength": 2048, "minLength": 1, "type": "string" @@ -513,18 +523,89 @@ "description": "JWKSURL is the URL to fetch the issuer's JSON Web Key Set from. If\nempty, it is resolved via OIDC discovery at\n{issuerUrl}/.well-known/openid-configuration.", "maxLength": 2048, "type": "string" + }, + "jwtBearerGrant": { + "description": "JWTBearerGrant enables the plain RFC 7523 JWT-bearer grant for this\nissuer. It is independent of RFC 8693 delegation policy.", + "properties": { + "acceptedAudiences": { + "description": "AcceptedAudiences identifies this authorization server's accepted\nassertion audiences. When omitted, runtime validation defaults to the\ntoken endpoint.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "maxAssertionAge": { + "description": "MaxAssertionAge caps the exp-iat interval independently of exp.", + "type": "string" + }, + "subjectBindings": { + "description": "SubjectBindings maps an exact external subject to allowed RFC 8707\nresources.", + "items": { + "description": "JWTBearerSubjectBinding configures the exact subject and allowed resources\nfor one RFC 7523 JWT-bearer assertion identity.", + "properties": { + "allowedResources": { + "description": "AllowedResources is the exact set of RFC 8707 resources this subject may\nrequest.", + "items": { + "maxLength": 2048, + "minLength": 1, + "pattern": "^https?://[^[:space:]]+$", + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + }, + "subject": { + "description": "Subject is an exact assertion sub value. Wildcards are not supported.", + "maxLength": 256, + "minLength": 1, + "pattern": "^[^*]+$", + "type": "string" + } + }, + "required": [ + "allowedResources", + "subject" + ], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array", + "x-kubernetes-list-type": "atomic" + } + }, + "required": [ + "maxAssertionAge", + "subjectBindings" + ], + "type": "object", + "x-kubernetes-validations": [ + { + "message": "maxAssertionAge must be greater than zero", + "rule": "duration(self.maxAssertionAge) > duration('0s')" + }, + { + "message": "subjectBindings must not contain duplicate subjects", + "rule": "self.subjectBindings.all(binding, self.subjectBindings.filter(other, other.subject == binding.subject).size() == 1)" + } + ] } }, "required": [ - "allowedDelegateClients", - "expectedAudience", "issuerUrl" ], "type": "object", "x-kubernetes-validations": [ { "message": "allowedDelegateClients must not combine the wildcard \"*\" with specific client IDs", - "rule": "!('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1" + "rule": "!has(self.allowedDelegateClients) || !('*' in self.allowedDelegateClients) || size(self.allowedDelegateClients) == 1" }, { "message": "allowMayAct must not be enabled when allowedDelegateClients contains the wildcard \"*\"", @@ -537,6 +618,14 @@ { "message": "allowPrivateIPs requires jwksUrl to be set explicitly", "rule": "!(has(self.allowPrivateIPs) && self.allowPrivateIPs) || (has(self.jwksUrl) && self.jwksUrl != \"\")" + }, + { + "message": "expectedAudience is required unless jwtBearerGrant is configured without actorClaim, actorMatcher, allowMayAct, or allowedActors", + "rule": "(has(self.jwtBearerGrant) && !((has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct) || (has(self.allowedActors) && size(self.allowedActors) > 0))) || (has(self.expectedAudience) && size(self.expectedAudience) > 0)" + }, + { + "message": "allowedDelegateClients is required when expectedAudience, actorClaim, actorMatcher, or allowMayAct is set", + "rule": "!((has(self.expectedAudience) && size(self.expectedAudience) > 0) || (has(self.actorClaim) && size(self.actorClaim) > 0) || (has(self.actorMatcher) && size(self.actorMatcher) > 0) || (has(self.allowMayAct) && self.allowMayAct)) || (has(self.allowedDelegateClients) && size(self.allowedDelegateClients) > 0)" } ] }, @@ -961,13 +1050,13 @@ "message": "allowConfidentialClientRegistration cannot be combined with insecureAllowHTTP; client secrets would be issued in cleartext over an unauthenticated endpoint", "rule": "!(has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration && has(self.insecureAllowHTTP) && self.insecureAllowHTTP)" }, - { - "message": "delegateClients require an https:// issuer; delegate client secrets must not be sent over plaintext HTTP", - "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://')" - }, { "message": "forceConfidentialRedirectUris requires allowConfidentialClientRegistration to be true", "rule": "(!has(self.forceConfidentialRedirectUris) || size(self.forceConfidentialRedirectUris) == 0) || (has(self.allowConfidentialClientRegistration) && self.allowConfidentialClientRegistration)" + }, + { + "message": "delegateClients with an HTTP issuer require insecureAllowConfidentialOverLoopbackHTTP to be explicitly enabled; the issuer must still be loopback", + "rule": "!has(self.delegateClients) || size(self.delegateClients) == 0 || !self.issuer.startsWith('http://') || (has(self.insecureAllowConfidentialOverLoopbackHTTP) && self.insecureAllowConfidentialOverLoopbackHTTP)" } ] }, @@ -2565,7 +2654,7 @@ }, "enablePrometheusMetricsPath": { "default": false, - "description": "EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.\nThe metrics are served on the main transport port at /metrics.\nThis is separate from OTLP metrics which are sent to the Endpoint.", + "description": "EnablePrometheusMetricsPath controls whether to expose Prometheus-style /metrics endpoint.\nThe metrics are served at /metrics on a dedicated diagnostics port rather than on the\nmain transport port, so the endpoint can be restricted by port and is not routed\nalongside application traffic. The endpoint is unauthenticated either way.\nSee PrometheusPort and pkg/diagnostics.\nThis is separate from OTLP metrics which are sent to the Endpoint.", "type": "boolean" }, "endpoint": { @@ -2596,6 +2685,14 @@ "description": "MetricsEnabled controls whether OTLP metrics are enabled.\nWhen false, OTLP metrics are not sent even if an endpoint is configured.\nThis is independent of EnablePrometheusMetricsPath.", "type": "boolean" }, + "metricsOnTransportPort": { + "description": "MetricsOnTransportPort controls whether /metrics is ALSO served on the main\ntransport port, in addition to the diagnostics port. It exists to give\ndeployments a migration window: while true, an existing scrape configuration\naimed at the transport port keeps working, and a new one aimed at\nPrometheusPort works too, so a scraper can be moved and verified before the\nold location goes away. See https://github.com/stacklok/toolhive/issues/6384 for\nthe removal timeline.", + "type": "boolean" + }, + "prometheusPort": { + "description": "PrometheusPort is the port the Prometheus /metrics endpoint is served on when\nEnablePrometheusMetricsPath is true. It is deliberately not the main transport port,\nso that access can be restricted with a NetworkPolicy: NetworkPolicy matches on port,\nnot on HTTP path, so a shared port makes \"allow MCP traffic, deny metrics scraping\"\nimpossible to express. The endpoint itself is unauthenticated, so restricting who can\nreach this port is how it is protected.\n\nZero selects the default diagnostics port (9464, the OpenTelemetry specification's\nPrometheus exporter default). If that port is taken the listener falls back to an\navailable one and logs the resolved address. Do not route this port publicly.", + "type": "integer" + }, "samplingRate": { "default": "0.05", "description": "SamplingRate is the trace sampling rate (0.0-1.0) as a string.\nOnly used when TracingEnabled is true.\nExample: \"0.05\" for 5% sampling.",