diff --git a/Chart.yaml b/Chart.yaml index 3583690..4b587f6 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: pgdog-control description: PgDog Control type: application -version: 0.4.2 +version: 0.4.3 appVersion: "main-ent" diff --git a/README.md b/README.md index 517cdcb..e6dcc49 100644 --- a/README.md +++ b/README.md @@ -313,12 +313,12 @@ control: In the above example, the dashboard can see workloads in every namespace, but it can only spin up or tear down PgDog deployments in `pgdog-prod` and `pgdog-staging`. Leaving `writeNamespaces` empty produces a fully read-only install. The dashboard still works, but the "deploy" actions will be rejected by the API server. -| Option | Description | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `control.rbac.create` | Render the ServiceAccount and the RBAC bindings. When `false`, no RBAC is rendered and the pod runs without a mounted API token. The Kubernetes views in the dashboard will be empty (bool, default `true`). | -| `control.rbac.serviceAccountName` | Override the generated ServiceAccount name. Empty falls back to `-control` (string, default `""`). | -| `control.rbac.existingServiceAccount` | Run the pod as this existing ServiceAccount. Requires `create: false`; see [Disabling RBAC](#disabling-rbac) (string, default `""`). | -| `control.rbac.writeNamespaces` | Namespaces where the control plane is allowed to manage PgDog workloads. Each entry produces one Role + RoleBinding pair. Empty means the install is read-only everywhere (list, default `[]`). | +| Option | Description | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `control.rbac.create` | Render the ServiceAccount and the RBAC bindings. When `false`, no RBAC is rendered and the pod runs without a mounted API token. The Kubernetes views in the dashboard will be empty (bool, default `true`). | +| `control.rbac.serviceAccountName` | Override the generated ServiceAccount name. Empty falls back to `-control` (string, default `""`). | +| `control.rbac.existingServiceAccount` | Run the pod as this existing ServiceAccount. Requires `create: false`; see [Disabling RBAC](#disabling-rbac) (string, default `""`). | +| `control.rbac.writeNamespaces` | Namespaces where the control plane is allowed to manage PgDog workloads. Each entry produces one Role + RoleBinding pair. Empty means the install is read-only everywhere (list, default `[]`). | ### Disabling RBAC @@ -559,6 +559,27 @@ If `allowed_cidrs` is omitted, the control plane defaults to private IPv4 ranges | `api.pgdog.ip_allowlist.enabled` | Enables source-IP checks for `/api/v2/*` PgDog endpoints (bool, default `false`). | | `api.pgdog.ip_allowlist.allowed_cidrs` | CIDR ranges allowed to call `/api/v2/*`. Invalid CIDRs cause protected requests to be rejected until the config is fixed (list of strings, default private IPv4 ranges, loopback, and IPv6 ULA). | +### MCP + +Set `control.config.mcp` to enable the `/mcp` endpoint for MCP clients. With Raft enabled, tokens can be generated and removed in System / MCP without configuring any tokens at startup: + +```yaml +raft: + enabled: true +control: + config: + mcp: + enabled: true + tokens: [] +``` + +Clients authenticate with `Authorization: Bearer `. These tokens are separate from `control.config.auth.tokens`, which protect the PgDog API. The MCP endpoint uses the control plane's existing port and ingress. + +| Option | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `mcp.enabled` | Enables the `/mcp` endpoint (bool, default `false`). | +| `mcp.tokens` | Optional configured MCP bearer tokens. When omitted, defaults to comma-separated `MCP_TOKENS`, or an empty list if unset. | + ### Authentication Set `control.config.auth.tokens` to restrict requests to the PgDog API to the listed Bearer tokens: @@ -610,6 +631,7 @@ Secrets are injected as environment variables. Leave corresponding inline values | Environment variable | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------- | | `AUTH_TOKENS` | Comma-separated Bearer token allowlist for `/api/v2`, sourced through `control.config.auth.secret.tokensKey`. | +| `MCP_TOKENS` | Comma-separated MCP bearer tokens, sourced through `control.config.mcp.secret.tokensKey`. | | `GITHUB_CLIENT_ID` | GitHub OAuth application ID. | | `GITHUB_CLIENT_SECRET` | GitHub OAuth application secret. | | `GOOGLE_CLIENT_ID` | Google OAuth application ID. | @@ -628,6 +650,7 @@ metadata: type: Opaque stringData: auth-tokens: "token-1,token-2" + mcp-tokens: "mcp-token-1,mcp-token-2" github-client-id: "your-github-client-id" github-client-secret: "your-github-client-secret" google-client-id: "your-google-client-id" @@ -642,6 +665,11 @@ Apply it in the release namespace with `kubectl apply -n -f ```yaml control: config: + mcp: + enabled: true + secret: + name: control-secrets + tokensKey: mcp-tokens auth: secret: name: control-secrets diff --git a/templates/configmap.yaml b/templates/configmap.yaml index 68c4e36..4b5998e 100644 --- a/templates/configmap.yaml +++ b/templates/configmap.yaml @@ -36,6 +36,17 @@ data: {{- end }} {{- end }} + {{- with $config.mcp }} + + [mcp] + {{- if hasKey . "enabled" }} + enabled = {{ .enabled }} + {{- end }} + {{- if hasKey . "tokens" }} + tokens = [{{ range $i, $token := .tokens }}{{ if $i }}, {{ end }}{{ $token | quote }}{{ end }}] + {{- end }} + {{- end }} + {{- with $config.rds }} [rds] {{- with .refresh_interval_secs }} diff --git a/templates/deployment.yaml b/templates/deployment.yaml index f1a5994..6dde867 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -162,6 +162,15 @@ spec: value: {{ . | quote }} {{- end }} {{- $auth := (.Values.control.config | default dict).auth | default dict }} + {{- with ((.Values.control.config | default dict).mcp | default dict).secret }} + {{- if .tokensKey }} + - name: MCP_TOKENS + valueFrom: + secretKeyRef: + name: {{ required "control.config.mcp.secret.name is required when tokensKey is set" .name | quote }} + key: {{ .tokensKey | quote }} + {{- end }} + {{- end }} {{- with $auth.secret }} {{- if .tokensKey }} - name: AUTH_TOKENS diff --git a/test/test-mcp.sh b/test/test-mcp.sh new file mode 100644 index 0000000..c653f05 --- /dev/null +++ b/test/test-mcp.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -eo pipefail + +TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHART_DIR="$TEST_DIR/.." + +mcp_config() { + helm template test-release "$CHART_DIR" --show-only templates/configmap.yaml "$@" \ + | yq -r '.data["control.toml"]' +} + +echo "==> Verifying MCP configuration..." +default_config=$(mcp_config) +if grep -q '^\[mcp\]$' <<< "$default_config"; then + echo "MCP configuration rendered with default values" >&2 + exit 1 +fi + +for raft in false true; do + enabled_config=$(mcp_config -f "$TEST_DIR/values-mcp.yaml" --set "raft.enabled=$raft") + grep -q '^\[mcp\]$' <<< "$enabled_config" + grep -q '^enabled = true$' <<< "$enabled_config" + grep -Fqx 'tokens = ["test-mcp-token", "test-mcp._~+/=token"]' <<< "$enabled_config" + + empty_config=$(mcp_config -f "$TEST_DIR/values-mcp.yaml" --set "raft.enabled=$raft" \ + --set-json 'control.config.mcp.tokens=[]') + grep -q '^\[mcp\]$' <<< "$empty_config" + grep -q '^enabled = true$' <<< "$empty_config" + grep -Fqx 'tokens = []' <<< "$empty_config" + + omitted_config=$(mcp_config --set control.config.mcp.enabled=true --set "raft.enabled=$raft") + grep -q '^\[mcp\]$' <<< "$omitted_config" + grep -q '^enabled = true$' <<< "$omitted_config" + if grep -q '^tokens = ' <<< "$omitted_config"; then + echo "MCP tokens rendered when omitted from values" >&2 + exit 1 + fi + + secret_render=$(helm template test-release "$CHART_DIR" -f "$TEST_DIR/values-secrets.yaml" \ + --set "raft.enabled=$raft") + secret_ref=$(yq -o=json -I=0 'select(.kind == "Deployment" or .kind == "StatefulSet") | .spec.template.spec.containers[] | select(.name == "control") | .env[] | select(.name == "MCP_TOKENS") | .valueFrom.secretKeyRef' <<< "$secret_render") + if [ "$secret_ref" != '{"name":"control-secrets","key":"mcp-tokens"}' ]; then + echo "MCP_TOKENS does not reference the configured Secret: $secret_ref" >&2 + exit 1 + fi + secret_config=$(mcp_config -f "$TEST_DIR/values-secrets.yaml" --set "raft.enabled=$raft") + grep -q '^\[mcp\]$' <<< "$secret_config" + if grep -q '^tokens = ' <<< "$secret_config"; then + echo "Rendered tokens would override MCP_TOKENS from the Secret" >&2 + exit 1 + fi +done + +if helm template test-release "$CHART_DIR" -f "$TEST_DIR/values-secrets.yaml" \ + --set control.config.mcp.secret.name= >/dev/null 2>&1; then + echo "MCP Secret reference rendered without a Secret name" >&2 + exit 1 +fi + +disabled_config=$(mcp_config -f "$TEST_DIR/values-mcp.yaml" \ + --set control.config.mcp.enabled=false --set-json 'control.config.mcp.tokens=[]') +grep -q '^\[mcp\]$' <<< "$disabled_config" +grep -q '^enabled = false$' <<< "$disabled_config" +grep -Fqx 'tokens = []' <<< "$disabled_config" + +echo "==> MCP configuration tests passed!" diff --git a/test/test.sh b/test/test.sh index 6c52971..ae658c4 100755 --- a/test/test.sh +++ b/test/test.sh @@ -64,4 +64,6 @@ if [ "$raft_annotation" != "24h" ]; then fi echo "" +bash "$TEST_DIR/test-mcp.sh" + echo "==> All chart tests passed!" diff --git a/test/values-mcp.yaml b/test/values-mcp.yaml new file mode 100644 index 0000000..7a66b02 --- /dev/null +++ b/test/values-mcp.yaml @@ -0,0 +1,7 @@ +control: + config: + mcp: + enabled: true + tokens: + - test-mcp-token + - test-mcp._~+/=token diff --git a/test/values-secrets.yaml b/test/values-secrets.yaml index b104747..40275f7 100644 --- a/test/values-secrets.yaml +++ b/test/values-secrets.yaml @@ -1,5 +1,10 @@ control: config: + mcp: + enabled: true + secret: + name: control-secrets + tokensKey: mcp-tokens auth: secret: name: control-secrets diff --git a/values.yaml b/values.yaml index 37d2e90..415fd2e 100644 --- a/values.yaml +++ b/values.yaml @@ -119,6 +119,16 @@ control: # # - ::1/128 # # - fc00::/7 # allowed_cidrs: [] + mcp: {} + # Enable the bearer-authenticated /mcp endpoint. Disabled by default. + # enabled: false + # Optional configured tokens, written to the control.toml ConfigMap. + # Omit or leave empty to use tokens managed through Raft in System → MCP. + # Authentication remains required when the list is empty. + # tokens: [] + # secret: + # name: "" # existing Secret in the release namespace + # tokensKey: "" # comma-separated list -> MCP_TOKENS; leave tokens unset rds: {} # refresh_interval_secs: 60 # Experimental: do not enable in production yet.