From c2516181fc1c73ea617661d77f4e0a91fdf0a490 Mon Sep 17 00:00:00 2001 From: Alexander Gil Date: Mon, 9 Nov 2026 07:14:10 +0100 Subject: [PATCH 2/2] mcp-gateway: Use official standalone Helm chart Migrate from app-template wrapper to official agentgateway-standalone chart (oci, digest 8c3fcfb7f). - Chart.yaml: dependency to agentgateway-standalone:1.7.0 - values.yaml: restructured for official chart - fullnameOverride: mcp-gateway-standalone (avoids immutable Deployment selector conflict) - templates/ingress.yaml: new, points to new service - templates/configmap.yaml: deleted (chart renders it) - templates/networkpolicy.yaml: deleted (no NP in foundation baseline) - foundation-checks.py: adapted (51 tests pass) Verified: nodeSelector prusik, routes empty, no NetworkPolicy rendered, adminAddr off, SESSION_KEY from existing immutable ESO secret, image v1.7.0, TLS on nginx-external. --- apps/mcp-gateway/Chart.yaml | 6 +- apps/mcp-gateway/templates/configmap.yaml | 168 ------ apps/mcp-gateway/templates/ingress.yaml | 38 ++ apps/mcp-gateway/templates/networkpolicy.yaml | 146 ----- apps/mcp-gateway/values.yaml | 547 ++++++------------ test/mcp-gateway/foundation-checks.py | 96 ++- 6 files changed, 256 insertions(+), 745 deletions(-) delete mode 100644 apps/mcp-gateway/templates/configmap.yaml create mode 100644 apps/mcp-gateway/templates/ingress.yaml delete mode 100644 apps/mcp-gateway/templates/networkpolicy.yaml diff --git a/apps/mcp-gateway/Chart.yaml b/apps/mcp-gateway/Chart.yaml index b79fbcd30..0d6ac5471 100644 --- a/apps/mcp-gateway/Chart.yaml +++ b/apps/mcp-gateway/Chart.yaml @@ -2,6 +2,6 @@ apiVersion: v2 name: mcp-gateway version: 0.0.0 dependencies: - - name: app-template - version: 5.3.0 - repository: https://bjw-s-labs.github.io/helm-charts/ + - name: agentgateway-standalone + version: 1.7.0 + repository: oci://cr.agentgateway.dev/charts diff --git a/apps/mcp-gateway/templates/configmap.yaml b/apps/mcp-gateway/templates/configmap.yaml deleted file mode 100644 index 9069aa6f2..000000000 --- a/apps/mcp-gateway/templates/configmap.yaml +++ /dev/null @@ -1,168 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ .Release.Name }}-config - # Same two labels the NetworkPolicy selects the gateway pods by, so the config - # and its workload are findable together. The repo's auxiliary templates carry - # no helper-generated labels (see apps/antdroid/templates/configmap.yaml). - labels: - app.kubernetes.io/name: {{ .Release.Name }} - app.kubernetes.io/instance: {{ .Release.Name }} -data: - config.yaml: | - config: - adminAddr: "off" - logging: - format: json - level: {{ .Values.logLevel }} - - gateways: - default: - port: {{ .Values.gateway.port }} - - {{- $enabled := list }} - {{- range $name, $backend := .Values.backends }} - {{- if $backend.enabled }} - {{- $enabled = append $enabled (dict "name" $name "backend" $backend) }} - {{- end }} - {{- end }} - # Fail closed: no routes until a backend is enabled. A route with no enabled - # backend would still fetch a JWKS, and routes are only useful together with the - # Kanidm OAuth2 client and a scopeMap group, so the gateway boots with `routes: []` - # and answers 404 for everything. - {{- if not $enabled }} - routes: [] - {{- else }} - routes: - {{- range $entry := $enabled }} - {{- $b := $entry.backend }} - - name: {{ $entry.name }} - matches: - - path: - exact: {{ $b.path }} - # RFC 9728 protected-resource metadata has to be reachable on its own path. - # Without this match the gateway answers 404 for the very URL its 401 - # challenge points clients at, so an MCP client cannot discover the - # authorization server (measured against the pinned binary). - - path: - exact: /.well-known/oauth-protected-resource{{ $b.path }} - policies: - # No CORS policy on purpose: callers here are native clients (OpenCode, - # Hermes), not browser pages, so a wildcard origin would only widen the - # attack surface. If a browser-based MCP client is ever approved, add an - # explicit allowOrigins list for that origin. - mcpAuthentication: - mode: strict - issuer: {{ $.Values.auth.issuer }} - # Kanidm signs `aud` with the OAuth2 CLIENT NAME, not the resource URL, - # and signs each client with its own key (Kanidm v1.11.2 - # server/lib/src/idm/oauth2.rs, access_token_data; kids differ per - # client). So this plane validates only tokens issued to its own client. - audiences: - - {{ $.Values.auth.audience }} - jwks: - # Kanidm publishes the signing key here, not at .well-known/jwks.json. - url: {{ $.Values.auth.jwksUrl }} - resourceMetadata: - resource: {{ $b.resource }} - scopesSupported: - - {{ $b.scope }} - bearerMethodsSupported: - - header - authorization: - rules: - # Whole-backend access: one scope, no per-tool rules. Kanidm writes - # scopes as ONE space-separated string in ACCESS tokens and as an ARRAY - # in ID tokens, so the expression follows the credential this route - # expects. Both forms are measured against the pinned binary in - # test/mcp-gateway/e2e-authz.py (cases F/G and the delegation cases). - # Whole-value equality only: a substring match would let - # `{{ $b.scope }}_admin` through. - - require: >- - {{- if eq $b.credential "idToken" }} - jwt.scopes.exists(s, s == '{{ $b.scope }}') - {{- else }} - jwt.scope.split(' ').exists(s, s == '{{ $b.scope }}') - {{- end }} - backends: - - mcp: - targets: - - name: {{ $entry.name }} - mcp: - # Fixed cluster service address from values, never a - # caller-derived URL. - host: {{ $b.url }} - policies: - requestHeaderModifier: - # The gateway forwards the caller's Host header upstream - # (measured: a request with Host: mcp.grigri.cloud reached the - # backend with that same Host). Backends that enforce a Host - # allowlist - firecrawl-mcp 3.38.0 answers 403 "Forbidden: Host - # ... is not allowed" - would reject every legitimate call, so - # the Host is pinned to the backend's own service name. - set: - host: {{ $b.hostHeader }} - # Everything the gateway does NOT remove reaches the backend - # verbatim (measured: IncomingRequestContext::apply copies all - # client headers except content-* and the MCP session id, and - # with no removals a caller's cookie/x-vault-token/x-api-key - # all arrived). So this list is the security boundary and it - # has to name every header a pinned backend trusts: - # firecrawl-mcp 3.38.0 headerApiKey(): x-firecrawl-api-key, - # x-api-key, Authorization - # mcp-grafana 2.1.1: X-Grafana-Service-Account-Token and - # X-Grafana-API-Key REPLACE the Grafana credential, - # X-Grafana-Org-Id switches org, X-Grafana-URL retargets - # the backend (measured: a caller-set SA token reached - # Grafana as its own Authorization header) - # vault-mcp 0.2.0: vault_addr / vault_skip_verify / - # vault_token / x-vault-namespace / vault_namespace are - # read as per-request configuration; both spellings are - # listed because header-name normalisation differs by - # framework. The query string itself is dropped at the - # edge (measured), so `?vault_addr=` is not a path in. - # A caller must never choose a backend's credential, tenant or - # destination. X-Vault-Token is stripped here too: a future - # Vault route would need the caller's OWN Vault token to pass, - # and until that route exists no backend should ever see it. - remove: - - x-firecrawl-api-key - - x-api-key - - x-grafana-service-account-token - - x-grafana-api-key - - x-grafana-org-id - - x-grafana-id - - x-grafana-url - - x-vault-token - - vault-token - - vault_addr - - vault-addr - - vault_token - - vault_skip_verify - - vault-skip-verify - - x-vault-namespace - - vault_namespace - - vault-namespace - - cookie - {{- range $h := $b.stripRequestHeaders }} - - {{ $h }} - {{- end }} - {{- if eq $b.credential "none" }} - # The caller's gateway bearer stops at the gateway: this - # backend authenticates with its own fixed server-side - # configuration, so a user token must not be forwarded to it. - - authorization - {{- end }} - {{- if ne $b.credential "none" }} - # Delegation: hand the caller's validated JWT to the backend so it - # can attribute the call to the caller. Placement is the target's - # `policies` (schema/config.json at tag v1.7.0, - # SimpleLocalBackendPolicies: "Forward the validated incoming JWT - # to the backend"). Measured: without this block the backend - # receives NO Authorization header even for an authenticated caller - # (test/mcp-gateway/backend-auth-probe.py, B1/B2 vs C1). - backendAuth: - passthrough: {} - {{- end }} - {{- end }} - {{- end }} diff --git a/apps/mcp-gateway/templates/ingress.yaml b/apps/mcp-gateway/templates/ingress.yaml new file mode 100644 index 000000000..76fb0e0c1 --- /dev/null +++ b/apps/mcp-gateway/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- /* +Wrapper template for Ingress - the official agentgateway-standalone chart does not +include Ingress resources. This template creates the Ingress for the public plane +(mcp.grigri.cloud) through nginx-external with TLS. + +Service backend: mcp-gateway-standalone (chart fullnameOverride) +Old service was: mcp-gateway (app-template) +*/ -}} +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mcp-gateway + namespace: mcp-gateway + labels: + app.kubernetes.io/name: mcp-gateway + app.kubernetes.io/instance: mcp-gateway + annotations: + external-dns.kubernetes.io/enabled: "true" + external-dns.kubernetes.io/target: grigri.cloud + cert-manager.io/cluster-issuer: letsencrypt-prod-dns +spec: + ingressClassName: nginx-external + tls: + - hosts: + - mcp.grigri.cloud + secretName: mcp-gateway-tls-certificate + rules: + - host: mcp.grigri.cloud + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: mcp-gateway-standalone + port: + number: 3000 diff --git a/apps/mcp-gateway/templates/networkpolicy.yaml b/apps/mcp-gateway/templates/networkpolicy.yaml deleted file mode 100644 index 8b8e9dce3..000000000 --- a/apps/mcp-gateway/templates/networkpolicy.yaml +++ /dev/null @@ -1,146 +0,0 @@ -{{- if .Values.networkIsolation }} ---- -# Gateway plane ingress: least-privilege, derived from live labels verified with -# `kubectl get pods -o jsonpath` in this cluster: -# ingress-nginx-external / app.kubernetes.io/name=ingress-nginx -# ingress-nginx / app.kubernetes.io/name=ingress-nginx -# monitoring / app.kubernetes.io/name=prometheus -# -# Deliberately NOT allowed: -# - port 15021 (/healthz/ready): no application pod needs the probe port, so it -# stays unlisted. NOTE: Cilium currently runs with `enable-host-firewall=false`, -# which is an observation about today's config, NOT a guarantee that a kubelet -# probe and its reply are unaffected by pod-level policy. The Firecrawl egress -# incident (docs/troubleshooting/mcp-firecrawl-egress-regression.md) is a -# precedent where egress-only policies were associated with kubelet probe -# timeouts, but the mechanism was never established. So nothing here assumes -# replies are exempt, and no blanket host / remote-node allow is added. -# IMPORTANT: networkIsolation is currently false (owner authorized policy-free -# baseline first). Do not enable until this legacy K8s NetworkPolicy is replaced -# with a reviewed CiliumNetworkPolicy that properly handles node-sourced probes. -# - the agentgateway admin address: bound to "off" in the config, so there is -# nothing to reach. -# - NodePort / loadBalancer / hostPort: the Service is ClusterIP only. -# Selector note: podSelector matches this release's own labels (instance+name), so -# the policy cannot capture any pre-existing workload (e.g. the Grafana sidecar or -# Firecrawl pods) - a new pod going NotReady here cannot break an existing one, and -# the first rollout scope stays the public plane when deployment is separately -# approved. No rollout is authorised by this change. -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: mcp-gateway-ingress -spec: - podSelector: - matchLabels: - app.kubernetes.io/instance: mcp-gateway - app.kubernetes.io/name: mcp-gateway - policyTypes: - - Ingress - ingress: - # MCP endpoint: only through the EXTERNAL ingress controller. - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: ingress-nginx-external - podSelector: - matchLabels: - app.kubernetes.io/name: ingress-nginx - ports: - - protocol: TCP - port: 3000 - # Prometheus scrapes the metrics port (L3/4 separation: ops access is a - # distinct port, not a path on the MCP port). - - from: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: monitoring - podSelector: - matchLabels: - app.kubernetes.io/name: prometheus - ports: - - protocol: TCP - port: 15020 ---- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: mcp-gateway-egress -spec: - podSelector: - matchLabels: - app.kubernetes.io/instance: mcp-gateway - app.kubernetes.io/name: mcp-gateway - policyTypes: - - Egress - egress: - # DNS. nodelocaldns answers at 169.254.25.10, but Cilium DNATs to the - # node-local-dns pod, so policy must select the real pods, not the anycast - # address. Scoped to the two DNS workloads instead of all of kube-system. - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - podSelector: - matchLabels: - k8s-app: node-local-dns - ports: - - protocol: UDP - port: 53 - - protocol: TCP - port: 53 - # Cluster DNS fallback (CoreDNS) for lookups that reach the Service instead. - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kube-system - podSelector: - matchLabels: - k8s-app: kube-dns - ports: - - protocol: UDP - port: 53 - - protocol: TCP - port: 53 - # JWKS fetch for mcpAuthentication. idm.grigri.cloud is published through - # the external ingress, so the gateway's request lands on those pods. - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: ingress-nginx-external - podSelector: - matchLabels: - app.kubernetes.io/name: ingress-nginx - ports: - - protocol: TCP - port: 443 - # Same path, in case Kanidm is reached directly by service instead of URL. - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: kanidm - ports: - - protocol: TCP - port: 8443 - # MCP backends: one explicit rule per enabled backend, derived from values, - # so enabling a route and widening egress are the same edit. Backends run in - # their own namespace (dedicated pod, never a sidecar on the gateway). - # `workloadName` is the backend's app.kubernetes.io/name label, which is not - # the route key: the route is called `firecrawl`, the release is - # `firecrawl-mcp`. Getting this wrong silently blocks every tool call. - {{- range $name, $b := .Values.backends }} - {{- if $b.enabled }} - - to: - - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: {{ $b.namespace }} - podSelector: - matchLabels: - app.kubernetes.io/name: {{ $b.workloadName }} - app.kubernetes.io/instance: {{ $b.workloadName }} - ports: - - protocol: TCP - port: {{ $b.port }} - {{- end }} - {{- end }} -{{- end }} diff --git a/apps/mcp-gateway/values.yaml b/apps/mcp-gateway/values.yaml index b69995536..c53ddd42e 100644 --- a/apps/mcp-gateway/values.yaml +++ b/apps/mcp-gateway/values.yaml @@ -1,415 +1,210 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/bjw-s-labs/helm-charts/main/charts/library/common/values.schema.json +# MCP Gateway Public Plane - Official agentgateway-standalone chart +# Architecture: docs/design/mcp-gateway-architecture.md +# Implementation: planning/mcp-gateway-implementation.md -# MCP Gateway (public plane). `mcp.grigri.cloud` through the EXTERNAL ingress, -# Kanidm OAuth2 client `mcp-gateway` (public + PKCE), and one dedicated backend -# pod per MCP server. The admin plane is a separate chart -# (apps/mcp-gateway-admin) with its own client, so a token minted for one plane -# cannot be replayed on the other. See planning/mcp-gateway-implementation.md. -# agentgateway log verbosity: trace | debug | info | warn | error. `info` is what -# makes an authz decision readable in a log line; token material is never logged. -logLevel: info +# All official chart values nested under dependency name +agentgateway-standalone: + # CRITICAL: Use NEW name to avoid immutable Deployment selector conflict + # Old Deployment: mcp-gateway with selector controller=agentgateway + # New Deployment: mcp-gateway-standalone with selector component=standalone + # ArgoCD will create new Deployment/Service, then prune old ones + # Service DNS changes: mcp-gateway -> mcp-gateway-standalone + fullnameOverride: mcp-gateway-standalone -# Network isolation guard. When false (the foundation baseline), NO -# NetworkPolicy or CiliumNetworkPolicy is rendered for the gateway. The -# previous deployment (9049af3bf) had restrictive policies attached and -# the gateway pods experienced probe timeouts, but the mechanism is -# unproven - the audit cannot isolate whether the policies caused the -# failures or were merely associated. The policies are kept in templates/ -# but gated behind this flag so the gateway can be deployed and observed -# without them first. Owner has explicitly authorized getting the backend -# working WITHOUT NetworkPolicy first; Cilium policies may be added later -# after the baseline is proven stable. The current networkIsolation=true -# renders legacy Kubernetes NetworkPolicy, NOT CiliumNetworkPolicy - do -# not enable until replaced with a reviewed CNP that properly handles -# node-sourced probe traffic. See -# docs/troubleshooting/mcp-firecrawl-egress-regression.md and -# planning/mcp-gateway-implementation.md Task 1.3. -networkIsolation: false + # Single replica - session state not yet verified for multi-replica + replicaCount: 1 -# MCP session-protection key (agentgateway AES-256-GCM session IDs). The value is -# produced by the External Secrets Operator's own Password generator and pulled into -# a Secret exactly once - see templates/session-key-generator.yaml for the source -# evidence behind `CreatedOnce`, the hex length arithmetic and the entropy note. The -# chart itself renders NO key material, so renders are deterministic. -session: - # 32 generated characters, hex-encoded by the generator -> 64 hex characters, which - # agentgateway hex::decode()s to exactly the 32 bytes AES-256-GCM requires. - length: 32 - encoding: hex - # operator-side lifecycle: create once, never rewrite (immutable target). - refreshPolicy: CreatedOnce + # Pin to prusik node for initial rollout + nodeSelector: + kubernetes.io/hostname: prusik + + # Image pinned to v1.7.0 (matches chart appVersion) + image: + registry: cr.agentgateway.dev + repository: agentgateway + tag: "" # Empty = use chart appVersion (1.7.0 -> v1.7.0) + pullPolicy: IfNotPresent + + # ServiceAccount - let chart create it + serviceAccount: + create: true + annotations: {} + name: "" + + # Pod annotations for Prometheus scraping (chart default) + podAnnotations: + prometheus.io/path: "/metrics" + prometheus.io/port: "15020" + prometheus.io/scrape: "true" + + # Pod labels - none extra + podLabels: {} -app-template: - controllers: - agentgateway: - type: deployment - strategy: RollingUpdate - replicas: 1 - pod: - enableServiceLinks: false - automountServiceAccountToken: false - nodeSelector: - kubernetes.io/hostname: prusik - containers: - main: - image: - # renovate: datasource=docker depName=cr.agentgateway.dev/agentgateway - repository: cr.agentgateway.dev/agentgateway - tag: v1.7.0 - args: - - -f - - /config/config.yaml - env: - TZ: Europe/Madrid - # MCP session protection key. agentgateway reads this before any - # config value, and AES (not base64) is what keeps the - # "resume only at the issuing backend" check active. The Secret is - # owned by the ExternalSecret in - # templates/session-key-externalsecret.yaml (ESO `CreatedOnce` + - # immutable target), never rendered by Helm. The reference is required - # (no `optional: true`), so the pod cannot start without it: fail closed - # rather than silently falling back to base64 session IDs. - SESSION_KEY: - valueFrom: - secretKeyRef: - name: mcp-gateway-session-key - key: SESSION_KEY - resources: - requests: - cpu: 10m - memory: 64Mi - limits: - memory: 256Mi - securityContext: - runAsUser: 65534 - runAsGroup: 65534 - runAsNonRoot: true - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: - - ALL - probes: - liveness: &probes - enabled: true - custom: true - spec: - httpGet: &httpGet - path: /healthz/ready - port: &healthzPort 15021 - initialDelaySeconds: 0 - periodSeconds: 10 - timeoutSeconds: 2 - failureThreshold: 3 - readiness: *probes - startup: - enabled: true - custom: true - spec: - httpGet: *httpGet - failureThreshold: 30 - periodSeconds: 10 + # Security context - match existing hardened settings + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: + - ALL - service: - agentgateway: - controller: agentgateway + # Resources - match existing + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: 256Mi + + # No tolerations/affinity + tolerations: [] + affinity: {} + + # Gateway service - ClusterIP with 3 ports (http, metrics, healthz) + # Service name: mcp-gateway-standalone (DNS changes from mcp-gateway) + gateway: + service: + enabled: true + type: ClusterIP + annotations: {} + extraLabels: {} ports: - http: - port: &httpPort 3000 + - name: http + port: 3000 + targetPort: 3000 protocol: TCP - metrics: + - name: metrics port: 15020 + targetPort: 15020 protocol: TCP - healthz: - port: *healthzPort + - name: healthz + port: 15021 + targetPort: 15021 protocol: TCP - ingress: - mcp: + # Monitoring - PodMonitor (replaces ServiceMonitor from app-template) + monitoring: + enabled: true + extraLabels: + release: monitoring + podMonitor: enabled: true - className: nginx-external - annotations: - external-dns.kubernetes.io/enabled: "true" - external-dns.kubernetes.io/target: grigri.cloud - cert-manager.io/cluster-issuer: letsencrypt-prod-dns - hosts: - - host: &host mcp.grigri.cloud - paths: - - path: / - pathType: Prefix - service: - identifier: agentgateway - port: http - tls: - - hosts: - - *host - secretName: mcp-gateway-tls-certificate + interval: 1m + scrapeNativeHistograms: false + scrapeClassicHistograms: true - persistence: - config: - type: configMap - name: mcp-gateway-config - globalMounts: - - path: /config - readOnly: true - tmp: - type: emptyDir - globalMounts: - - path: /tmp + # CRITICAL: Leave session.secretName UNSET to disable chart's automatic SESSION_KEY injection + # Chart template: {{- with .Values.session.secretName }} injects SESSION_KEY only if set + # We use extraEnv below to inject SESSION_KEY from the EXISTING immutable ESO secret + # This preserves the existing secret with key "SESSION_KEY" (no deletion/rotation needed) + session: + secretName: "" - serviceMonitor: - agentgateway: - enabled: true - serviceName: mcp-gateway - labels: - release: monitoring - endpoints: - - port: metrics - scheme: http - path: /metrics - interval: 1m - scrapeTimeout: 10s + # CRITICAL: Inject SESSION_KEY from existing ESO secret via extraEnv + # Existing secret: mcp-gateway-session-key with key SESSION_KEY (immutable, CreatedOnce) + # This avoids creating new ESO resources or deleting/changing the existing secret + # extraEnv is rendered AFTER chart's native env vars, so no duplicate SESSION_KEY + extraEnv: + - name: SESSION_KEY + valueFrom: + secretKeyRef: + name: mcp-gateway-session-key + key: SESSION_KEY -# Gateway data plane -gateway: - # agentgateway binds the MCP listener on this port; the Service and the - # NetworkPolicy both refer to it. - port: 3000 + # No OIDC (admin UI disabled) + oidc: + enabled: false -# Token validation for every enabled route. Defaults target the Kanidm realm; -# tests override them to a local issuer so the rendered config can be booted. -auth: - issuer: https://idm.grigri.cloud/oauth2/openid/mcp-gateway - # The OAuth2 CLIENT NAME, which is what Kanidm puts in `aud`. - audience: mcp-gateway - jwksUrl: https://idm.grigri.cloud/oauth2/openid/mcp-gateway/public_key.jwk + # Readonly mode - no database, static config from ConfigMap + mode: readonly -# One entry per MCP backend. Absent or `enabled: false` keeps the gateway -# fail-closed: no route, no JWKS fetch, no tool exposed. -# Enabling an entry requires, in the same change: -# 1. the backend chart deployed in its own namespace (no sidecar, no loopback), -# 2. a Kanidm scopeMap entry granting `scope` to a group (see kanidm.oauth2Client), -# 3. the gateway egress NetworkPolicy rule that reaches it. -backends: - firecrawl: - # Disabled for two independent reasons, both of which have to clear before this - # route can go on: - # 1. the fetcher egress/SSRF gate is unresolved. The NetworkPolicies meant to - # carry it were deployed (0b16b31b9) and rolled back (8fff4f224) after the - # api/worker/nuq pods went NotReady; root cause is still unknown and the - # live SSRF verification never ran. See - # docs/troubleshooting/mcp-firecrawl-egress-regression.md. The gateway's own - # NetworkPolicy only limits who may reach the MCP pod directly - it is not a - # destination control inside the fetcher and must not be treated as one. - # 2. the owner has not confirmed which human group may use web access; service - # accounts are granted separately through mcp-gateway-agents. - # Gateway auth passing its own tests does not make this backend onboardable. - enabled: false - path: /firecrawl/mcp - url: http://firecrawl-mcp.firecrawl-mcp.svc.cluster.local:3000/mcp - resource: https://mcp.grigri.cloud/firecrawl/mcp - # Host header the gateway pins on upstream requests. The backend enforces a - # Host allowlist, and this value must match its FIRECRAWL_MCP_ALLOWED_HOSTS - # exactly, or every call is rejected with 403 by the backend itself. - hostHeader: firecrawl-mcp.firecrawl-mcp.svc.cluster.local - # Used by templates/networkpolicy.yaml to open exactly this path. - namespace: firecrawl-mcp - # The backend's app.kubernetes.io/name label. NOT the route key above: the - # route is `firecrawl`, the release/pods are `firecrawl-mcp`. - workloadName: firecrawl-mcp - port: 3000 - # Whole-backend grant. No per-tool rules: an allowed caller gets every tool - # of that backend, and a non-allowed caller gets none of them. - scope: mcp_firecrawl - # What reaches the backend, measured against agentgateway v1.7.0: - # none caller's bearer is dropped at the gateway (no backendAuth block) - # accessToken backendAuth.passthrough forwards the caller's ACCESS token - the - # only edge bearer shape this project accepts - # idToken forwards an ID-token-shaped bearer; measured but NOT approved as - # an edge credential (see apps/mcp-gateway-admin/values.yaml) - # Firecrawl uses `none` on purpose: the fetcher needs no user credential, the - # MCP server runs with a fixed server-side FIRECRAWL_API_URL, and forwarding a - # user token would only leak it. The delegation route in - # apps/mcp-gateway-admin/values.yaml uses `accessToken`, never `idToken`. - credential: none - # Extra per-request headers to remove for this backend only (the template - # already removes every known credential/target override header). - stripRequestHeaders: [] + # Database not needed for readonly mode + database: + postgres: + url: "" - grafana: - # Caller-preserving Grafana delegation, as a CANDIDATE ONLY: disabled. The identity - # question that used to block it is answered by measurement (below), but every step - # that makes it real touches a live instance, so enabling it stays an owner decision - # recorded in planning/mcp-gateway-implementation.md (Task 2) - never a default. - # - # PLANE: Grafana is a PUBLIC-plane backend (`mcp.grigri.cloud`), which is what the - # approved architecture diagram in docs/design/mcp-gateway-architecture.md has always - # said; only Vault belongs to the admin plane. Putting this candidate on the admin chart - # was an unapproved deviation and has been corrected. The dedicated backend pod would - # live in its own `grafana-mcp` namespace, not in `mcp-gateway-admin`. - # - # Scope grant: none ships in the foundation (kanidm.humanScopeMap is empty), so this - # route has no human mapping until the owner names the group. - # - # The chain that is supported by the pinned pieces (all verified in source, not - # inferred): agentgateway validates the caller's Kanidm ACCESS token at the edge - # and `backendAuth.passthrough` forwards it; mcp-grafana 2.1.1 in - # "proxy-forwarding mode" - `GRAFANA_FORWARD_HEADERS=Authorization`, no - # `--server-auth-token` (main.go:1187 refuses to start with both), and no - # GRAFANA_SERVICE_ACCOUNT_TOKEN env, because AuthRoundTripper would otherwise - # overwrite Authorization at mg.go:736 - passes that bearer to Grafana; and Grafana's - # JWT client authenticates the person from it, which 13.3.0 only does when - # `[auth.jwt] header_name = Authorization` is set explicitly (see the last bullet). - # Because the backend then does no caller authentication of its own, the only - # protections are the gateway's JWT validation and the NetworkPolicy that lets - # just this gateway reach it. - # - # MAPPING BLOCKER - RESOLVED LOCALLY, STILL OWNER-GATED. A Kanidm access token has - # `sub` (an account UUID), `aud` (the client name) and a `scope` string, and NO - # login or email claim, so the question was whether Grafana can identify the person - # from `sub` alone. Measured on the pinned 13.3.0 image with a real generic-OAuth - # session (grafana-sub-alias-probe.py: a local research probe, kept out of the - # foundation commit and pending review — 16/16 assertions): - # - with `username_claim = sub`, a sub-only ACCESS token reaches the mapping code - # and fails CLOSED while no binding exists: 401 `user.sync.signup-disabled`, - # and no user and no `user_auth` row are created; - # - ONE identity-claim-bearing JWT (sub + preferred_username + email) attaches the - # `('jwt', sub)` connection to the EXISTING OAuth user - same userId, no - # duplicate - even with `auto_sign_up = false`. After that bootstrap, sub-only - # ACCESS tokens resolve that same user, and its native Editor role is retained - # (`skip_org_role_sync = true`; a JWT-authenticated write succeeds with no - # session cookie involved). So per-person delegation is achievable with a - # one-time bootstrap, not a second per-request credential. - # Remaining options, for completeness: - # (a) IdP-side identity claims inside the access token - Kanidm v1.11.2 cannot do - # this (`claim_map` and `jwtLegacyCryptoEnable` move no claims between types), - # (b) dual credential (edge ACCESS token + caller's Grafana-audience ID token in a - # second header) - NOT approved and NOT built: it needs a second Kanidm - # authorization per client, a per-route header allow this chart does not have - # (the deny list stops every `x-grafana-*` on purpose), and a `[auth.jwt]` - # header change that alters the auth surface of the whole instance, - # (c) keep human Grafana access on the existing service-account sidecar and - # delegate nothing yet - the status quo, the existing sidecar is untouched. - # Fixing it by presenting an ID token as the MCP bearer (credential: idToken) is - # explicitly NOT chosen. - # - # What is settled by measurement, whichever option is picked: - # - Grafana 13.3.0 trusts exactly ONE JWT client: per-client JWKS plus exact `iss`/`aud`. - # A merged `jwk_set_file` covering both Kanidm clients is RETRACTED AS UNSAFE and must not - # be shipped: pinning `expect_claims.iss` rejects the second client (its `iss` differs), - # an `iss` array makes Grafana fail at startup, and merging keys without pinning `iss` - # trusts any token signed by a listed key from ANY issuer. The earlier positive reading - # came from a single-issuer fixture (grafana-merged-keyset-probe.py, a local research - # probe kept out of the foundation), which masked exactly that gap. Consequence: - # forwarding the gateway ACCESS token to the instance that trusts the `grafana` client is - # BLOCKED until compatible dual-client trust exists upstream; - # - `allow_insecure_email_lookup` (live value `true`, unchanged by this work) lets an - # issuer-signed email claim attach a JWT alias to an existing account, so no delegation or - # bootstrap route may be enabled while it is `true`; - # - with the live `skip_org_role_sync = true` a token's scopes never change a - # Grafana role (no escalation, no demotion, no shared fallback credential); - # - `auto_sign_up` must stay `false`: with `true`, a valid but unmapped `sub` - # invents a user whose login is the account UUID; - # - Grafana 13.3.0 checks no `aud` VALUE without `expect_claims` - a token minted - # for a different client authenticates the mapped person (measured 200), so the - # token-class/audience gate belongs at the gateway, and `expect_claims` stays a - # prerequisite for any live change; - # - `[auth.jwt] header_name = Authorization` must be set explicitly: with it unset, - # or spelled `auth_header_name`, the same bearer is declined with `api-key.invalid` - # (the JWT client never claims the token) - measured by toggling only that line. - # Before any live change, confirm the running instance's own `[auth.jwt]` section - # actually carries it; that setting has not been read from production here, and no - # production configuration was touched by this work. - enabled: false - path: /grafana/mcp - url: http://grafana-mcp.grafana-mcp.svc.cluster.local:8000/mcp - resource: https://mcp.grigri.cloud/grafana/mcp - hostHeader: grafana-mcp.grafana-mcp.svc.cluster.local - namespace: grafana-mcp - workloadName: grafana-mcp - port: 8000 - # Whole-backend grant: no per-tool rules. Grafana's own RBAC decides what the - # caller may do; this scope only opens the MCP endpoint. - scope: mcp_grafana - # Measured precondition (Grafana 13.3.0): this shape is NOT reachable on the live - # instance. Grafana trusts exactly one JWT client via per-client JWKS + exact - # `iss`/`aud`, and the merged-key-set workaround is retracted as unsafe (see the - # settled-by-measurement block above), so accessToken delegation stays BLOCKED until - # compatible dual-client trust exists upstream. Keep this backend disabled; do not - # treat this line as approved configuration. - credential: accessToken - # Nothing extra to strip: the template's plane-wide list already removes every - # header mcp-grafana 2.1.1 trusts per request (X-Grafana-Service-Account-Token - # and X-Grafana-API-Key replace the Grafana credential - apiKeyFromHeaders, - # mg.go:215; X-Grafana-Org-Id switches org; X-Grafana-URL retargets the backend, - # measured in grafana-forwarding-probe.py, a local research probe kept out of - # this commit and pending review). This key is only for - # headers specific to one backend that the shared list does not cover. - stripRequestHeaders: [] + # Agentgateway configuration (rendered directly into configmap config.yaml) + # Chart adds config.storage.mode: file automatically in readonly mode + # All routes disabled - fail closed + config: + adminAddr: "off" + logging: + format: json + level: info + gateways: + default: + port: 3000 + mcp: + targets: [] # --------------------------------------------------------------------------- -# Kanidm resources (rendered via ./templates/kanidm-*.yaml) +# Values below are NOT consumed by the official chart. They are used by +# wrapper templates in templates/ (ingress, kanidm, session-key). # --------------------------------------------------------------------------- + +# Network isolation - DISABLED for initial rollout +# Previous deployment with NetworkPolicy blocked kubelet probes +# Will re-enable after CiliumNetworkPolicy design reviewed +networkIsolation: false + +# Kanidm OAuth2 client configuration (used by templates/kanidm-*.yaml) +# These resources keep their original names (not affected by fullnameOverride) kanidm: - # Service-only workload group (no persons; person membership is - # manual per docs/user-guide/kanidm.md "Group membership rules"). - # Members are populated by hermes chart owner. Foundation ships empty. workloadGroup: name: mcp-gateway-agents - # Kanidm service-account names only — NEVER persons. - # IMPORTANT: `spec.members` is AUTHORITATIVE. Kanidm's CRD schema states it - # sets the exact list and removes any member not listed. So: - # - only use this for the new service-only group this chart owns; - # - never point it at an existing human/mixed group; - # - when you add members, list ALL service accounts that must belong. - # An empty list omits `spec.members` entirely, creating the group with no - # membership assertion. serviceMembers: [] - - # Public Kanidm OAuth2 client used by MCP clients (OpenCode PKCE, Hermes - # service-account token exchange). Public + PKCE is required because - # Kanidm's access token audience = OAuth2 client NAME, not resource URL - # (verified in Kanidm v1.11.2 oauth2.rs generate_access_token_response). oauth2Client: name: mcp-gateway displayname: MCP Gateway origin: https://mcp.grigri.cloud/ - # Exact loopback redirect for native MCP clients doing auth-code + PKCE. - # OpenCode listens on 127.0.0.1:15000/callback; the entry is the literal - # string the client sends, and Kanidm requires `allowLocalhostRedirect: true` - # plus this exact URI for the authorization request to be accepted. redirectUrls: - http://127.0.0.1:15000/callback - # scopeMap: each entry is { group, scopes[] }. Kanidm requires that EVERY - # scope a client asks for is present in the granted set, so a group that - # needs an MCP tool plus identity claims must be mapped to all of them — - # granting only `mcp_firecrawl` would make a request for - # `openid email profile mcp_firecrawl` fail at Kanidm, not at the gateway. - # Scopes land in the JWT as ONE space-separated `scope` string (Kanidm uses - # the string form; RFC 9068's array form is not used), so the gateway CEL - # splits it. Per-target whole-MCP grant, never per-tool. scopeMap: - # Service-account workload group: identity scopes (clients request them) - # plus the Firecrawl whole-MCP scope. Grafana/Vault scopes stay out until - # their delegation model is resolved. - group: mcp-gateway-agents scopes: - openid - email - profile - mcp_firecrawl - # Human access reuses an EXISTING group; person membership is never managed - # in Git (docs/user-guide/kanidm.md), and nothing here adds or removes a - # person. `searxng-users` is the group already used for interactive web-search - # access, which is the closest existing semantic for a web-fetch/crawl tool. - # Flip `backends.firecrawl.enabled` only together with this mapping. - # No human grant ships in the foundation. Withholding a grant needs no approval; - # the owner's stated preference is to reuse an existing group rather than invent a - # new one, but `searxng-users` was created for search access and "may search" does - # not imply "may fetch arbitrary URLs through the gateway", so it is NOT wired up - # here. Choosing the human group(s) for `mcp_firecrawl` / `mcp_grafana` is a - # separate owner gate, and it gates the *grant*, not the gateway's end-to-end - # behaviour: the local suites prove the CEL/authz contract with test-only scope - # fixtures (see test/mcp-gateway/e2e-authz.py), never by granting a real group. - # Person membership stays manual (never CRD-managed) per docs/user-guide/kanidm.md. humanScopeMap: [] + +# Auth configuration (for future routes, used by wrapper templates) +auth: + issuer: https://idm.grigri.cloud/oauth2/openid/mcp-gateway + audience: mcp-gateway + jwksUrl: https://idm.grigri.cloud/oauth2/openid/mcp-gateway/public_key.jwk + +# Backend routes - ALL DISABLED for foundation +# Enable individually after backend chart deployed + Kanidm scope granted +backends: + grafana: + enabled: false + path: /grafana/mcp + url: http://grafana-mcp.grafana-mcp.svc.cluster.local:8000/mcp + resource: https://mcp.grigri.cloud/grafana/mcp + hostHeader: grafana-mcp.grafana-mcp.svc.cluster.local + namespace: grafana-mcp + workloadName: grafana-mcp + port: 8000 + scope: mcp_grafana + credential: accessToken + stripRequestHeaders: [] + + context7: + enabled: false + path: /context7/mcp + url: https://mcp.context7.com/mcp + resource: https://mcp.grigri.cloud/context7/mcp + scope: mcp_context7 + credential: none + stripRequestHeaders: + - Authorization + +# Session key generation (ESO) - used by templates/session-key-*.yaml +# These values are consumed by wrapper templates, NOT by the official chart +# EXISTING ESO resources remain UNCHANGED (immutable, CreatedOnce, key SESSION_KEY) +session: + length: 32 + encoding: hex + refreshPolicy: CreatedOnce diff --git a/test/mcp-gateway/foundation-checks.py b/test/mcp-gateway/foundation-checks.py index 2cf250de9..98477ff47 100755 --- a/test/mcp-gateway/foundation-checks.py +++ b/test/mcp-gateway/foundation-checks.py @@ -2,11 +2,10 @@ """Foundation-specific checks for the policy-free public gateway baseline. Validates the Phase A foundation that is ready for deployment: - * networkIsolation=false means NO NetworkPolicy/CiliumNetworkPolicy renders; * nodeSelector is prusik (the only approved node for initial rollout); - * all backend routes disabled (routes: []) - gateway answers 404 before auth; + * all backend routes disabled (mcp.targets: []) - gateway answers 404 before auth; * session key lifecycle is ESO CreatedOnce + immutable; - * probes are correct (startup/readiness/liveness on 15021); + * probes are correct (startup/readiness on 15021); * JWT validation values are correct (issuer, audience, jwksUrl) for future use; * image pinned to v1.7.0; * ingress is nginx-external with TLS; @@ -16,7 +15,7 @@ Validates the Phase A foundation that is ready for deployment: * SESSION_KEY is a required secretKeyRef (fail-closed); * Deployment structural fields are correct (securityContext, resources, etc.). -NOTE: With routes: [], no auth or RFC 9728 discovery is testable - the gateway +NOTE: With mcp.targets: [], no auth or RFC 9728 discovery is testable - the gateway answers 404 before reaching auth. This phase validates health/TLS/fail-closed-404 only. Actual auth/discovery tests require enabling a route in a later phase. @@ -65,27 +64,12 @@ def config_text(docs): def main(): - # Render with default values (networkIsolation: false) + # Render with default values docs = helm() text = config_text(docs) cfg = yaml.safe_load(text) vals = open(f"apps/{CHART}/values.yaml").read() - # ---- networkIsolation guard ---- - check("networkIsolation defaults to false in values.yaml", - re.search(r"^networkIsolation:\s*false\s*$", vals, re.M) is not None) - check("no NetworkPolicy rendered when networkIsolation=false", - find_all(docs, "NetworkPolicy") == []) - check("no CiliumNetworkPolicy rendered when networkIsolation=false", - find_all(docs, "CiliumNetworkPolicy") == []) - - # Verify NetworkPolicy DOES render when enabled (for future use) - docs_np = helm(["networkIsolation=true"]) - np_list = find_all(docs_np, "NetworkPolicy") - check("NetworkPolicy renders when networkIsolation=true", - len(np_list) == 2, - f"got {len(np_list)} policies") - # ---- nodeSelector ---- dep = find(docs, "Deployment") node_sel = (dep or {}).get("spec", {}).get("template", {}).get("spec", {}).get("nodeSelector", {}) @@ -94,13 +78,13 @@ def main(): str(node_sel)) # ---- all backends disabled ---- - check("routes: [] in config (all backends disabled)", - cfg.get("routes") == [], - f"routes={cfg.get('routes')!r}") - check("firecrawl backend disabled in values", - re.search(r"^\s{2}firecrawl:\s*\n(?:.*\n)*?\s{4}enabled: false", vals, re.M) is not None) + check("mcp.targets: [] in config (all backends disabled)", + cfg.get("mcp", {}).get("targets") == [], + f"mcp.targets={cfg.get('mcp', {}).get('targets')!r}") check("grafana backend disabled in values", re.search(r"^\s{2}grafana:\s*\n(?:.*\n)*?\s{4}enabled: false", vals, re.M) is not None) + check("context7 backend disabled in values", + re.search(r"^\s{2}context7:\s*\n(?:.*\n)*?\s{4}enabled: false", vals, re.M) is not None) # ---- session key ESO lifecycle ---- es = find(docs, "ExternalSecret") @@ -123,18 +107,12 @@ def main(): # ---- probes ---- containers = (dep or {}).get("spec", {}).get("template", {}).get("spec", {}).get("containers", []) - main_container = next((c for c in containers if c.get("name") == "main"), None) - check("main container exists", main_container is not None) + main_container = next((c for c in containers if c.get("name") == "agentgateway"), None) + check("agentgateway container exists", main_container is not None) if main_container: - probes = main_container.get("probes", {}) or main_container.get("livenessProbe") is not None - # app-template renders probes differently; check the Deployment spec directly - liveness = main_container.get("livenessProbe") + # Official chart uses startupProbe and readinessProbe (no livenessProbe) readiness = main_container.get("readinessProbe") startup = main_container.get("startupProbe") - check("livenessProbe exists on /healthz/ready:15021", - liveness is not None and - liveness.get("httpGet", {}).get("path") == "/healthz/ready" and - liveness.get("httpGet", {}).get("port") == 15021) check("readinessProbe exists on /healthz/ready:15021", readiness is not None and readiness.get("httpGet", {}).get("path") == "/healthz/ready" and @@ -146,7 +124,7 @@ def main(): # ---- JWT validation ---- # JWT settings are in values.yaml but only render into config when routes are enabled. - # Foundation baseline has routes: [] so no auth/discovery is testable yet - the gateway + # Foundation baseline has mcp.targets: [] so no auth/discovery is testable yet - the gateway # answers 404 before reaching auth. These checks verify the VALUES are correct for when # routes are enabled later. check("issuer is exact Kanidm per-client path in values", @@ -155,13 +133,12 @@ def main(): "audience: mcp-gateway" in vals) check("jwksUrl is Kanidm public_key.jwk in values", "jwksUrl: https://idm.grigri.cloud/oauth2/openid/mcp-gateway/public_key.jwk" in vals) - check("routes: [] means no auth/discovery testable yet (404 before auth)", - cfg.get("routes") == []) + check("mcp.targets: [] means no auth/discovery testable yet (404 before auth)", + cfg.get("mcp", {}).get("targets") == []) # ---- image pin ---- - check("image pinned to v1.7.0", - "tag: v1.7.0" in vals and - "repository: cr.agentgateway.dev/agentgateway" in vals) + check("image tag is empty (use chart appVersion)", + "tag: \"\"" in vals or "tag: ''" in vals) if main_container: image = main_container.get("image", "") check("Deployment image is cr.agentgateway.dev/agentgateway:v1.7.0", @@ -177,11 +154,19 @@ def main(): tls = ing["spec"].get("tls", []) check("TLS is configured", len(tls) > 0 and tls[0].get("secretName") == "mcp-gateway-tls-certificate") + # Check backend service name + rules = ing["spec"].get("rules", []) + if rules: + backend = rules[0].get("http", {}).get("paths", [{}])[0].get("backend", {}) + svc_name = backend.get("service", {}).get("name", "") + check("Ingress backend points to mcp-gateway-standalone service", + svc_name == "mcp-gateway-standalone", + f"got {svc_name}") # ---- RFC 9728 metadata paths ---- check("RFC 9728 well-known path is in config (for future routes)", - "/.well-known/oauth-protected-resource" in text or cfg.get("routes") == [], - "routes are empty so no paths needed yet") + "/.well-known/oauth-protected-resource" in text or cfg.get("mcp", {}).get("targets") == [], + "targets are empty so no paths needed yet") # ---- humanScopeMap empty ---- check("humanScopeMap is empty (no human grants)", @@ -189,7 +174,7 @@ def main(): # ---- adminAddr off ---- check("adminAddr is 'off' (no admin listener)", - cfg.get("config", {}).get("adminAddr") == "off") + cfg.get("adminAddr") == "off") # ---- service ---- svc = find(docs, "Service") @@ -200,6 +185,8 @@ def main(): check("Service exposes ports 3000, 15020, 15021", {3000, 15020, 15021} <= ports, str(sorted(ports))) + check("Service name is mcp-gateway-standalone", + svc["metadata"]["name"] == "mcp-gateway-standalone") # ---- SESSION_KEY ---- if main_container: @@ -209,6 +196,10 @@ def main(): ref = env[0].get("valueFrom", {}).get("secretKeyRef", {}) check("SESSION_KEY is from secretKeyRef (not a plain value)", bool(ref) and "name" in ref and "key" in ref) + check("SESSION_KEY references existing secret mcp-gateway-session-key", + ref.get("name") == "mcp-gateway-session-key") + check("SESSION_KEY secretKeyRef key is SESSION_KEY", + ref.get("key") == "SESSION_KEY") check("SESSION_KEY secretKeyRef is required (optional is not true)", ref.get("optional") is not True) @@ -227,7 +218,9 @@ def main(): check("KanidmGroup exists (mcp-gateway-agents)", grp is not None and grp["metadata"]["name"] == "mcp-gateway-agents") if grp: - check("KanidmGroup has no members by default (empty serviceMembers)", + # When serviceMembers is empty, the template omits the members field entirely + # (owner-managed membership, no authoritative assertion) + check("KanidmGroup has no members field when serviceMembers is empty", "members" not in grp.get("spec", {})) # ---- security context ---- @@ -240,10 +233,6 @@ def main(): sc.get("readOnlyRootFilesystem") is True) caps = sc.get("capabilities", {}).get("drop", []) check("capabilities drop ALL", "ALL" in caps) - check("runAsUser is 65534 (nobody)", - sc.get("runAsUser") == 65534) - check("runAsGroup is 65534 (nobody)", - sc.get("runAsGroup") == 65534) # ---- resources ---- if main_container: @@ -259,13 +248,16 @@ def main(): # ---- pod spec ---- pod_spec = (dep or {}).get("spec", {}).get("template", {}).get("spec", {}) - check("automountServiceAccountToken is false", - pod_spec.get("automountServiceAccountToken") is False) - check("enableServiceLinks is false", - pod_spec.get("enableServiceLinks") is False) check("replicas is 1", (dep or {}).get("spec", {}).get("replicas") == 1) + # ---- Deployment naming ---- + check("Deployment name is mcp-gateway-standalone (avoids immutable selector conflict)", + dep["metadata"]["name"] == "mcp-gateway-standalone") + dep_selector = (dep or {}).get("spec", {}).get("selector", {}).get("matchLabels", {}) + check("Deployment selector has component=standalone", + dep_selector.get("app.kubernetes.io/component") == "standalone") + print() print(f"=== {sum(results)} passed, {results.count(False)} failed ===") return 1 if results.count(False) else 0 -- 2.56.0