diff --git a/config/ingress/base/traefik.yaml b/config/ingress/base/traefik.yaml index 493dce28..09b4a839 100644 --- a/config/ingress/base/traefik.yaml +++ b/config/ingress/base/traefik.yaml @@ -74,9 +74,23 @@ rules: - apiGroups: ["networking.k8s.io"] resources: ["ingressclasses"] verbs: ["get", "list", "watch"] - - apiGroups: ["traefik.io"] + # Traefik v2.10 starts informers for every CRD kind in BOTH the traefik.io and + # legacy traefik.containo.us groups when those CRDs are installed. A forbidden + # list/watch on any one of them stalls the CRD provider so no IngressRoute is + # loaded and mtls hosts 404. Grant the full kind set in both groups. + - apiGroups: ["traefik.io", "traefik.containo.us"] resources: - ["ingressroutes", "ingressroutetcps", "middlewares", "tlsoptions", "tlsstores", "serverstransports", "traefikservices"] + [ + "ingressroutes", + "ingressroutetcps", + "ingressrouteudps", + "middlewares", + "middlewaretcps", + "tlsoptions", + "tlsstores", + "serverstransports", + "traefikservices", + ] verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/config/ingress/overlays/http/deployment-args.patch.yaml b/config/ingress/overlays/http/deployment-args.patch.yaml index 49e5076a..ad88966c 100644 --- a/config/ingress/overlays/http/deployment-args.patch.yaml +++ b/config/ingress/overlays/http/deployment-args.patch.yaml @@ -3,15 +3,27 @@ value: - --providers.kubernetesingress=true - --providers.kubernetesingress.namespaces=registry,mcp-sentinel,mcp-servers,mcp-servers-org,mcp-servers-public + # The mtls terminate+re-encrypt path is delivered entirely through Traefik + # CRDs (IngressRoute + Middleware + TLSOption + ServersTransport). Without the + # CRD provider Traefik ignores them and the mtls host 404s, so enable it for + # the same server namespaces. web stays plaintext (no redirect) for the HTTP + # test flows. + - --providers.kubernetescrd=true + - --providers.kubernetescrd.namespaces=mcp-servers,mcp-servers-org,mcp-servers-public - --entrypoints.web.address=:8000 - --entrypoints.web.forwardedHeaders.insecure=false - --entrypoints.websecure.address=:8443 - --entrypoints.websecure.forwardedHeaders.insecure=false + - --entrypoints.websecure.http.tls=true - --entrypoints.traefik.address=:8080 - --ping=true - --ping.entryPoint=traefik - --providers.file.filename=/etc/traefik/dynamic/dynamic.yml - --providers.file.watch=true + # The mtls Middleware injects the verified SPIFFE identity via this local + # plugin (source is mounted by the base Deployment); register it so the + # middleware resolves instead of failing the route. + - --experimental.localPlugins.spiffe-identity.moduleName=github.com/Agent-Hellboy/mcp-runtime/traefik-plugins/spiffe-identity - op: replace path: /spec/template/spec/containers/0/ports value: diff --git a/internal/cli/setup/platform/analytics.go b/internal/cli/setup/platform/analytics.go index e0b6d90c..46b0afd1 100644 --- a/internal/cli/setup/platform/analytics.go +++ b/internal/cli/setup/platform/analytics.go @@ -677,9 +677,6 @@ func renderAnalyticsManifest(content string, images AnalyticsImageSet, imagePull if mode, ok := setupplan.NormalizePlatformMode(platformMode); ok && mode != "" { replacements[`PLATFORM_MODE: "tenant"`] = fmt.Sprintf(`PLATFORM_MODE: "%s"`, mode) } - if issuer := strings.TrimSpace(os.Getenv("MCP_MTLS_CLUSTER_ISSUER")); issuer != "" { - replacements[`MCP_MTLS_CLUSTER_ISSUER: ""`] = fmt.Sprintf(`MCP_MTLS_CLUSTER_ISSUER: %q`, issuer) - } if strings.TrimSpace(images.Ingest) != "" { replacements["image: mcp-sentinel-ingest:latest"] = "image: " + images.Ingest } @@ -800,6 +797,7 @@ func renderAnalyticsConfigManifestWithReaders(content, platformMode string, imag "PLATFORM_REGISTRY_URL", "PLATFORM_TRAEFIK_NAMESPACE", "PLATFORM_TEAM_TRAEFIK_WATCH", + "MCP_MTLS_CLUSTER_ISSUER", } { if envValue := setupAnalyticsConfigEnvValue(key); envValue != "" { manifest.Data[key] = envValue diff --git a/internal/cli/setup/platform/helpers_test.go b/internal/cli/setup/platform/helpers_test.go index e40da7b1..86cba240 100644 --- a/internal/cli/setup/platform/helpers_test.go +++ b/internal/cli/setup/platform/helpers_test.go @@ -1485,6 +1485,39 @@ func TestRenderAnalyticsSecretManifestDisablesExistingDevLoginsOutsideTestMode(t } } +func TestRenderAnalyticsConfigManifestInjectsMTLSClusterIssuer(t *testing.T) { + t.Setenv("MCP_MTLS_CLUSTER_ISSUER", "mcp-runtime-ca") + mock := &core.MockExecutor{ + CommandFunc: func(spec core.ExecSpec) *core.MockCommand { + return &core.MockCommand{Args: spec.Args} + }, + } + kubectl := core.NewTestKubectlClient(mock) + + rendered, err := renderAnalyticsConfigManifest(kubectl, `apiVersion: v1 +kind: ConfigMap +metadata: + name: mcp-sentinel-config + namespace: mcp-sentinel +data: + MCP_MTLS_CLUSTER_ISSUER: "" + PLATFORM_MODE: "tenant" +`, setupplan.PlatformModeTenant, AnalyticsImageSet{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var payload struct { + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal([]byte(rendered), &payload); err != nil { + t.Fatalf("unmarshal rendered config: %v", err) + } + if got := payload.Data["MCP_MTLS_CLUSTER_ISSUER"]; got != "mcp-runtime-ca" { + t.Fatalf("expected MCP_MTLS_CLUSTER_ISSUER to be injected into the sentinel ConfigMap, got %q", got) + } +} + func TestRenderAnalyticsConfigManifestPreservesExistingPublicOAuthConfig(t *testing.T) { mock := &core.MockExecutor{ CommandFunc: func(spec core.ExecSpec) *core.MockCommand { diff --git a/internal/operator/controller_test.go b/internal/operator/controller_test.go index 7558df80..503e5085 100644 --- a/internal/operator/controller_test.go +++ b/internal/operator/controller_test.go @@ -1089,6 +1089,34 @@ func TestCheckIngressReady(t *testing.T) { assertEqual(t, "ready", ready, false) }) + t.Run("mtls server is ready when its IngressRoute exists", func(t *testing.T) { + mtlsScheme := traefikScheme(t) + server := mtlsServer() + route := crFixture(ingressRouteGVK, server.Name, server.Namespace) + client := fake.NewClientBuilder().WithScheme(mtlsScheme).WithObjects(server, route).Build() + r := MCPServerReconciler{Client: client, Scheme: mtlsScheme} + ready, err := r.checkIngressReady(context.Background(), server) + if err != nil { + t.Fatalf("failed to check ingress readiness: %v", err) + } + assertEqual(t, "ready", ready, true) + }) + + t.Run("mtls server is not ready without an IngressRoute", func(t *testing.T) { + mtlsScheme := traefikScheme(t) + server := mtlsServer() + // Only the legacy passthrough IngressRouteTCP exists; the terminate-and- + // re-encrypt model no longer uses it, so the server must not read ready. + legacy := crFixture(ingressRouteTCPGVK, server.Name, server.Namespace) + client := fake.NewClientBuilder().WithScheme(mtlsScheme).WithObjects(server, legacy).Build() + r := MCPServerReconciler{Client: client, Scheme: mtlsScheme} + ready, err := r.checkIngressReady(context.Background(), server) + if err != nil { + t.Fatalf("failed to check ingress readiness: %v", err) + } + assertEqual(t, "ready", ready, false) + }) + t.Run("returns true when ingress has load balancer status", func(t *testing.T) { mcpServer := &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "test-server", Namespace: "default"}, diff --git a/internal/operator/mtls.go b/internal/operator/mtls.go index b8fac959..9ff44820 100644 --- a/internal/operator/mtls.go +++ b/internal/operator/mtls.go @@ -414,9 +414,20 @@ func (r *MCPServerReconciler) reconcileMTLSIngress(ctx context.Context, mcpServe "match": match, "kind": "Rule", "middlewares": []any{map[string]any{"name": mtlsMiddlewareName(mcpServer)}}, + // The IngressRoute references the Kubernetes Service port (ServicePort, + // typically 80), not the gateway container port. Traefik resolves + // endpoints via the Service and connects to each pod at the targetPort + // (Gateway.Port, e.g. 8091) automatically. Using the container port + // directly causes "service port not found" because that port number does + // not appear in the Service's spec.ports. "services": []any{map[string]any{ - "name": mcpServer.Name, - "port": int64(mcpServer.Spec.Gateway.Port), + "name": mcpServer.Name, + "port": int64(mcpServer.Spec.ServicePort), + // scheme: https tells Traefik to connect to the backend over TLS. + // Without it, Traefik defaults to HTTP (port 80 is not 443), and + // the ServersTransport TLS config is ignored, causing the gateway to + // return 400 "Client sent an HTTP request to an HTTPS server". + "scheme": "https", "serversTransport": mtlsServersTransportName(mcpServer), }}, }}, diff --git a/internal/operator/mtls_certs_test.go b/internal/operator/mtls_certs_test.go index 5bfc0ae6..01560a7e 100644 --- a/internal/operator/mtls_certs_test.go +++ b/internal/operator/mtls_certs_test.go @@ -19,9 +19,10 @@ func mtlsServer() *mcpv1alpha1.MCPServer { return &mcpv1alpha1.MCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "secure-server", Namespace: "mcp-servers"}, Spec: mcpv1alpha1.MCPServerSpec{ - Image: "example.com/secure-server", - Gateway: &mcpv1alpha1.GatewayConfig{Enabled: true, Port: 8091, Image: "example.com/gw:latest"}, - Auth: &mcpv1alpha1.AuthConfig{Mode: mcpv1alpha1.AuthModeMTLS, TrustDomain: "example.org"}, + Image: "example.com/secure-server", + ServicePort: 80, + Gateway: &mcpv1alpha1.GatewayConfig{Enabled: true, Port: 8091, Image: "example.com/gw:latest"}, + Auth: &mcpv1alpha1.AuthConfig{Mode: mcpv1alpha1.AuthModeMTLS, TrustDomain: "example.org"}, }, } } diff --git a/internal/operator/mtls_ingress_test.go b/internal/operator/mtls_ingress_test.go index 2497c9c8..44f52176 100644 --- a/internal/operator/mtls_ingress_test.go +++ b/internal/operator/mtls_ingress_test.go @@ -102,13 +102,27 @@ func TestReconcileMTLSIngressGeneratesTraefikResources(t *testing.T) { if len(routes) != 1 { t.Fatalf("routes = %d, want 1", len(routes)) } - match, _ := routes[0].(map[string]any)["match"].(string) + route0 := routes[0].(map[string]any) + match, _ := route0["match"].(string) if !strings.Contains(match, "Host(`mcp.example.com`)") || !strings.Contains(match, "PathPrefix(`/secure-server/mcp`)") { t.Fatalf("match = %q, want host + path prefix", match) } if tlsName, _, _ := unstructured.NestedString(ir.Object, "spec", "tls", "options", "name"); tlsName != mtlsTLSOptionName(server) { t.Fatalf("tls.options.name = %q", tlsName) } + // The IngressRoute must reference the Kubernetes Service port (80), not the + // gateway container port (8091). Traefik resolves endpoints from the Service + // and then connects to pod:targetPort — using the container port causes + // "service port not found" because it never appears in Service.spec.ports. + services, _, _ := unstructured.NestedSlice(route0, "services") + if len(services) != 1 { + t.Fatalf("route services = %d, want 1", len(services)) + } + svcPort, _, _ := unstructured.NestedFieldNoCopy(services[0].(map[string]any), "port") + if svcPort != int64(server.Spec.ServicePort) { + t.Fatalf("IngressRoute service port = %v, want ServicePort (%d) not Gateway.Port (%d)", + svcPort, server.Spec.ServicePort, server.Spec.Gateway.Port) + } } func TestReconcileMTLSIngressNeverSetsPerRouteSecretName(t *testing.T) { diff --git a/internal/operator/status.go b/internal/operator/status.go index ea6f6061..0241bf6d 100644 --- a/internal/operator/status.go +++ b/internal/operator/status.go @@ -70,8 +70,11 @@ func (r *MCPServerReconciler) checkServiceReady(ctx context.Context, mcpServer * func (r *MCPServerReconciler) checkIngressReady(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (bool, error) { if serverUsesMTLS(mcpServer) { + // The terminate-and-re-encrypt mTLS model serves traffic through a + // path-based Traefik IngressRoute (the legacy passthrough IngressRouteTCP + // is deleted during reconcile), so readiness must track the IngressRoute. route := &unstructured.Unstructured{} - route.SetGroupVersionKind(ingressRouteTCPGVK) + route.SetGroupVersionKind(ingressRouteGVK) if err := r.Get(ctx, types.NamespacedName{Name: mcpServer.Name, Namespace: mcpServer.Namespace}, route); err != nil { if errors.IsNotFound(err) { return false, nil diff --git a/services/mcp-gateway/cert_reload_test.go b/services/mcp-gateway/cert_reload_test.go index d1ceb1c6..84c45ab9 100644 --- a/services/mcp-gateway/cert_reload_test.go +++ b/services/mcp-gateway/cert_reload_test.go @@ -106,15 +106,18 @@ func TestCertReloaderWatchReloadsOnChange(t *testing.T) { defer cancel() go r.watch(ctx, 20*time.Millisecond) - // Bump modtime into the future so the poll reliably detects the change. second := writeKeyPair(t, certPath, keyPath, "cert-2") - future := time.Now().Add(2 * time.Second) - _ = os.Chtimes(certPath, future, future) - // Generous deadline so the 20ms poll is detected even when CI runs the - // service test suites in parallel under a constrained CPU quota. + // watch() captures its baseline modtime asynchronously, so under CI load it + // may start after this rewrite and baseline on cert-2's modtime — then it + // would never observe a "change" and never reload. Push the modtime strictly + // forward on every poll so the watcher sees a fresh change regardless of when + // its goroutine started; that makes the reload deterministic rather than + // racing the goroutine start. deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { + for i := 1; time.Now().Before(deadline); i++ { + future := time.Now().Add(time.Duration(i) * time.Second) + _ = os.Chtimes(certPath, future, future) if string(servedLeaf(t, r)) == string(second) { return } diff --git a/services/runtime-api/internal/runtimeapi/adapter_certificate.go b/services/runtime-api/internal/runtimeapi/adapter_certificate.go index e28b775d..013e7fae 100644 --- a/services/runtime-api/internal/runtimeapi/adapter_certificate.go +++ b/services/runtime-api/internal/runtimeapi/adapter_certificate.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "net/http" "os" @@ -196,7 +197,10 @@ func (s *AccessService) issueSessionCertificateDER( }, }, "spec": map[string]any{ - "request": base64.StdEncoding.EncodeToString(csrDER), + // cert-manager's CertificateRequest.spec.request must be a PEM-encoded + // CSR; sending raw DER makes the admission webhook reject it with + // "error decoding certificate request PEM block". + "request": base64.StdEncoding.EncodeToString(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})), "duration": duration.Round(time.Second).String(), "usages": []any{"digital signature", "client auth"}, "issuerRef": map[string]any{ diff --git a/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go b/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go index 31d60e58..7e90737c 100644 --- a/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go +++ b/services/runtime-api/internal/runtimeapi/adapter_certificate_test.go @@ -1,12 +1,22 @@ package runtimeapi import ( + "context" + "encoding/base64" + "encoding/pem" "strings" "testing" + "time" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + clienttesting "k8s.io/client-go/testing" "mcp-runtime/pkg/certauth" + "mcp-runtime/pkg/identity" + "mcp-runtime/pkg/k8sclient" ) func TestValidateAdapterCSRUsesCertauth(t *testing.T) { @@ -24,6 +34,53 @@ func TestValidateAdapterCSRUsesCertauth(t *testing.T) { } } +func TestIssueSessionCertificateSubmitsPEMRequest(t *testing.T) { + // cert-manager's admission webhook rejects a CertificateRequest whose + // spec.request is not a PEM-encoded CSR, so the submitted request must be + // PEM (base64 of the PEM block), not raw DER. + t.Setenv("MCP_MTLS_CLUSTER_ISSUER", "mcp-runtime-ca") + + scheme := runtime.NewScheme() + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, + map[schema.GroupVersionResource]string{certificateRequestGVR: "CertificateRequestList"}) + var capturedRequest string + dyn.PrependReactor("create", "certificaterequests", func(action clienttesting.Action) (bool, runtime.Object, error) { + obj := action.(clienttesting.CreateAction).GetObject().(*unstructured.Unstructured) + capturedRequest, _, _ = unstructured.NestedString(obj.Object, "spec", "request") + return false, nil, nil + }) + + svc := &AccessService{k8sClients: &k8sclient.Clients{Dynamic: dyn}} + + const trust, ns, sess = "cluster.local", "mcp-servers", "sess-1" + _, csrPEM, _, err := certauth.BuildSessionCSR(trust, ns, sess) + if err != nil { + t.Fatalf("BuildSessionCSR: %v", err) + } + csrDER, err := certauth.ValidateCSRPEM(string(csrPEM), identity.SessionSPIFFEID(trust, ns, sess)) + if err != nil { + t.Fatalf("ValidateCSRPEM: %v", err) + } + + // The wait step errors out (no accessMgr / no signer), but the create — and + // thus our capture — happens first, which is all this test asserts. + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _, _, _ = svc.issueSessionCertificateDER(ctx, ns, sess, csrDER, time.Hour) + + if capturedRequest == "" { + t.Fatal("no CertificateRequest was created") + } + decoded, err := base64.StdEncoding.DecodeString(capturedRequest) + if err != nil { + t.Fatalf("spec.request is not valid base64: %v", err) + } + block, _ := pem.Decode(decoded) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + t.Fatalf("spec.request must be a PEM CERTIFICATE REQUEST, got: %q", string(decoded)) + } +} + func TestAdapterCertificateRequestFailure(t *testing.T) { t.Parallel() request := &unstructured.Unstructured{Object: map[string]any{ diff --git a/test/e2e/kind.sh b/test/e2e/kind.sh index 17d61ce2..3721da89 100644 --- a/test/e2e/kind.sh +++ b/test/e2e/kind.sh @@ -10,7 +10,7 @@ set -euo pipefail # # Set E2E_SCENARIOS to a comma-separated subset for local debugging. # Supported values: all, smoke-auth, governance, trust, oauth, observability, -# multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform. +# multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform, mtls. # observability requires the full traffic suite: smoke-auth, governance, trust, oauth. # # Set E2E_DEEP_REQUEST_FLOWS=1 for pre-release runs that should exercise @@ -366,11 +366,11 @@ validate_scenarios() { local scenario for scenario in "${E2E_SCENARIO_LIST[@]}"; do case "${scenario}" in - all|smoke-auth|governance|trust|oauth|observability|multitenancy|api-platform|ui-auth|adapter-proxy|cli-platform) + all|smoke-auth|governance|trust|oauth|observability|multitenancy|api-platform|ui-auth|adapter-proxy|cli-platform|mtls) ;; *) echo "unsupported E2E scenario: ${scenario}" >&2 - echo "supported values: all, smoke-auth, governance, trust, oauth, observability, multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform" >&2 + echo "supported values: all, smoke-auth, governance, trust, oauth, observability, multitenancy, api-platform, ui-auth, adapter-proxy, cli-platform, mtls" >&2 exit 1 ;; esac @@ -388,7 +388,7 @@ validate_scenarios() { if deep_request_flows_enabled; then local required - for required in smoke-auth governance trust oauth observability multitenancy api-platform ui-auth adapter-proxy cli-platform; do + for required in smoke-auth governance trust oauth observability multitenancy api-platform ui-auth adapter-proxy cli-platform mtls; do if ! scenario_selected "${required}"; then echo "E2E_DEEP_REQUEST_FLOWS=1 requires all E2E scenarios" >&2 echo "set E2E_SCENARIOS=all or include every supported scenario" >&2 @@ -842,6 +842,62 @@ recover_traefik_port_forward_if_needed() { wait_port "${TRAEFIK_PORT}" 30 } +# recover_traefik_tls_port_forward_if_needed mirrors recover_traefik_port_forward_if_needed +# for the websecure (TLS) entrypoint. The TLS port-forward can die when Traefik +# reloads its mTLS config (TLSOption / ServersTransport) after cert-manager issues +# the CA bundle secrets — a watch-event-driven reload briefly closes the listener, +# which breaks the port-forward with "broken pipe". Call this before any mTLS curl +# that runs after the cert-issuance wait. +recover_traefik_tls_port_forward_if_needed() { + if port_is_listening "${TRAEFIK_TLS_PORT}"; then + return 0 + fi + + if [[ -n "${TRAEFIK_TLS_PORT_FORWARD_PID:-}" ]] && kill -0 "${TRAEFIK_TLS_PORT_FORWARD_PID}" >/dev/null 2>&1; then + kill "${TRAEFIK_TLS_PORT_FORWARD_PID}" >/dev/null 2>&1 || true + wait "${TRAEFIK_TLS_PORT_FORWARD_PID}" >/dev/null 2>&1 || true + fi + TRAEFIK_TLS_PORT_FORWARD_PID="" + + TRAEFIK_TLS_PORT_FORWARD_RESTARTS=$((TRAEFIK_TLS_PORT_FORWARD_RESTARTS + 1)) + local log_file="${WORKDIR}/traefik-tls-port-forward-restart-${TRAEFIK_TLS_PORT_FORWARD_RESTARTS}.log" + echo "[port-forward] restarting Traefik TLS port-forward on localhost:${TRAEFIK_TLS_PORT}" >&2 + port_forward_bg traefik traefik "${TRAEFIK_TLS_PORT}" 8443 "${log_file}" || return 1 + TRAEFIK_TLS_PORT_FORWARD_PID="${LAST_MANAGED_PID}" + wait_port "${TRAEFIK_TLS_PORT}" 30 +} + +# wait_for_mtls_traefik_stable polls Traefik pod logs until the mTLS server's +# router and transport config have been applied error-free for a stable window. +# Traefik retries loading missing secrets with exponential backoff, emitting +# level=error lines for each failed attempt. This function waits until no such +# errors appear in a 6s window, proving that TLSOption (RequireAndVerifyClientCert +# + CA) and ServersTransport (TLS to gateway) are both fully loaded. +# +# This log-based approach is used instead of probing the websecure port because +# in Kind clusters kubectl port-forward exits on any TCP error from the upstream +# pod (broken pipe, connection reset), including the RST Traefik sends after a +# TLS certificate_required alert. Probing via curl therefore creates an infinite +# port-forward restart loop and never produces a usable readiness signal. +wait_for_mtls_traefik_stable() { + local server_name="$1" + local traefik_ns="${TRAEFIK_NAMESPACE:-traefik}" + local deadline=$((SECONDS + 60)) + echo "[mtls] waiting for Traefik to apply ${server_name} mTLS config without errors" >&2 + while [[ $SECONDS -lt $deadline ]]; do + sleep 3 + local recent + recent="$(kubectl logs -n "${traefik_ns}" deploy/traefik --since=6s 2>/dev/null \ + | grep "level=error.*${server_name}" || true)" + if [[ -z "${recent}" ]]; then + echo "[mtls] Traefik mTLS config stable (no ${server_name} errors in last 6s)" >&2 + return 0 + fi + done + echo "[mtls] WARNING: Traefik mTLS config may not be fully stable, proceeding" >&2 + return 0 +} + ensure_traefik_port_forward() { if [[ -n "${TRAEFIK_PORT_FORWARD_PID:-}" ]] && ! port_is_listening "${TRAEFIK_PORT}"; then if kill -0 "${TRAEFIK_PORT_FORWARD_PID}" >/dev/null 2>&1; then @@ -960,6 +1016,9 @@ ensure_gateway_port_forward() { wait_port "${SENTINEL_PORT}" } +# shellcheck source=scenarios/mtls.sh +source "${PROJECT_ROOT}/test/e2e/scenarios/mtls.sh" + refresh_mcp_proxy_urls() { MCP_INGRESS_PATH="/${SERVER_NAME}/mcp" MCP_DIRECT_URL="http://127.0.0.1:${TRAEFIK_PORT}${MCP_INGRESS_PATH}" @@ -1726,17 +1785,18 @@ PY wait_for_policy_text() { local text="$1" - local tries="${2:-40}" + local server="${2:-${SERVER_NAME}}" + local tries="${3:-40}" local i for i in $(seq 1 "${tries}"); do local current - current="$(kubectl get configmap "${SERVER_NAME}-gateway-policy" -n mcp-servers -o "jsonpath={.data.policy\.json}" 2>/dev/null || true)" + current="$(kubectl get configmap "${server}-gateway-policy" -n mcp-servers -o "jsonpath={.data.policy\.json}" 2>/dev/null || true)" if [[ "${current}" == *"${text}"* ]]; then return 0 fi sleep 2 done - echo "timed out waiting for policy text: ${text}" >&2 + echo "timed out waiting for policy text in ${server}-gateway-policy: ${text}" >&2 return 1 } @@ -6057,6 +6117,11 @@ fi fi +if scenario_selected "mtls"; then + run_e2e_mtls_scenario + cleanup_mcp_server_and_wait "${MTLS_SERVER_NAME}" mcp-servers 120s +fi + echo "[cli] checking sentinel restart command" # The full E2E stack packs single-node Kind tightly, so avoid requiring surge CPU for this restart smoke. refresh_kind_kubeconfig diff --git a/test/e2e/scenarios/mtls.sh b/test/e2e/scenarios/mtls.sh new file mode 100644 index 00000000..c824abb0 --- /dev/null +++ b/test/e2e/scenarios/mtls.sh @@ -0,0 +1,432 @@ +# mTLS datapath checks for Kind E2E. Sourced from kind.sh when the mtls scenario +# is selected. Requires test-mode setup (managed mcp-runtime-ca issuer). + +MTLS_SERVER_NAME="${MTLS_SERVER_NAME:-mtls-mcp-server}" +MTLS_TRUST_DOMAIN="${MTLS_TRUST_DOMAIN:-cluster.local}" +TRAEFIK_TLS_PORT="${TRAEFIK_TLS_PORT:-18443}" +MTLS_AGENT_ID="${MTLS_AGENT_ID:-e2e-mtls-agent}" +# A dedicated ingress host so the operator generates a Host()-scoped IngressRoute. +# Traefik binds the client-cert TLS options to that SNI, so the mtls handshake is +# only enforced when the caller's SNI matches (curl reaches it via --resolve). +MTLS_INGRESS_HOST="${MTLS_INGRESS_HOST:-mtls-mcp-server.e2e.local}" + +# mtls_dump_diagnostics captures the control-plane state needed to debug an mtls +# datapath failure into WORKDIR, which the EXIT trap archives to the CI artifact +# bundle. Without this, an mtls failure (e.g. adapter cert issuance) leaves no +# server-side evidence in the artifacts and can only be diagnosed by local repro. +mtls_dump_diagnostics() { + local rc=$? + local out="${WORKDIR}/mtls-diagnostics" + local traefik_ns="${TRAEFIK_NAMESPACE:-traefik}" + mkdir -p "${out}" + echo "[mtls] capturing failure diagnostics (exit=${rc}) to ${out}" >&2 + + # --- MCPServer / workload state --- + kubectl get mcpserver "${MTLS_SERVER_NAME}" -n mcp-servers -o yaml >"${out}/mcpserver.yaml" 2>&1 || true + kubectl get all -n mcp-servers >"${out}/mcp-servers-all.txt" 2>&1 || true + kubectl describe pods -n mcp-servers -l "app=${MTLS_SERVER_NAME}" >"${out}/mtls-server-pods-describe.txt" 2>&1 || true + kubectl get events -n mcp-servers --sort-by=.lastTimestamp >"${out}/mcp-servers-events.txt" 2>&1 || true + + # --- Certificates / issuance --- + kubectl get certificaterequests -n mcp-servers -o yaml >"${out}/certificaterequests.yaml" 2>&1 || true + kubectl get certificates,secrets -n mcp-servers >"${out}/certs-and-secrets.txt" 2>&1 || true + kubectl get configmap mcp-sentinel-config -n mcp-sentinel -o yaml >"${out}/sentinel-config.yaml" 2>&1 || true + + # --- Traefik routing layer (why a request 404s / 502s) --- + # The mtls datapath depends on Traefik CRDs (IngressRoute + Middleware + + # TLSOption + ServersTransport) being provisioned AND picked up by Traefik's + # CRD provider. Dump the CRs and the controller so we can tell an operator + # provisioning gap from a Traefik provider/namespace-scope gap. + # Fully-qualified resource names: the cluster registers both traefik.io and + # traefik.containo.us API groups, so bare names are ambiguous and kubectl + # returns an empty list instead of the CRs. + kubectl get ingressroutes.traefik.io,ingressroutetcps.traefik.io,middlewares.traefik.io,tlsoptions.traefik.io,serverstransports.traefik.io,tlsstores.traefik.io \ + -A -o yaml >"${out}/traefik-crs.yaml" 2>&1 || true + kubectl get ingressroutes.traefik.containo.us,middlewares.traefik.containo.us,tlsoptions.traefik.containo.us,serverstransports.traefik.containo.us \ + -A -o yaml >"${out}/traefik-crs-containous.yaml" 2>&1 || true + kubectl get deploy -n "${traefik_ns}" traefik -o yaml >"${out}/traefik-deploy.yaml" 2>&1 || true + kubectl logs -n "${traefik_ns}" deploy/traefik --tail=400 >"${out}/traefik.log" 2>&1 || true + + # --- Sentinel APIs (adapter cert issuance path) --- + kubectl logs -n mcp-sentinel deploy/mcp-runtime-api --tail=400 >"${out}/runtime-api.log" 2>&1 || true + kubectl logs -n mcp-sentinel deploy/mcp-platform-api --tail=200 >"${out}/platform-api.log" 2>&1 || true + kubectl logs -n mcp-servers -l "app=${MTLS_SERVER_NAME}" --all-containers=true --tail=200 >"${out}/mtls-server.log" 2>&1 || true + + # --- Gateway policy: the ConfigMap (API view) vs the file the gateway + # actually reads (kubelet-propagated volume). A session present in the + # ConfigMap but missing from the file means kubelet volume propagation lag, + # not an operator or gateway bug. --- + kubectl get configmap "${MTLS_SERVER_NAME}-gateway-policy" -n mcp-servers -o yaml \ + >"${out}/gateway-policy-configmap.yaml" 2>&1 || true + local diag_pod + diag_pod="$(kubectl get pod -n mcp-servers -l "app=${MTLS_SERVER_NAME}" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" + if [[ -n "${diag_pod}" ]]; then + kubectl exec -n mcp-servers "${diag_pod}" -c mcp-gateway -- \ + cat /var/run/mcp-runtime/policy/policy.json >"${out}/gateway-policy-file-in-pod.json" 2>&1 || true + fi + + # --- Replay the datapath request verbosely so the exact HTTP status, + # response headers and body (Traefik 404 vs gateway error vs deny) are in the + # artifact even when the assertion curl ran quietly. --- + if [[ -n "${MTLS_URL:-}" && -n "${MTLS_RESOLVE:-}" ]]; then + local -a replay=(curl -sS -k -v -o "${out}/datapath-replay-body.txt" + -w 'HTTP %{http_code}\n' --resolve "${MTLS_RESOLVE}" + -H "content-type: application/json" + -H "accept: application/json, text/event-stream" + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" + --data '{"jsonrpc":"2.0","id":99,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"diag","version":"1"}}}') + if [[ -s "${MTLS_CLIENT_CERT:-}" && -s "${MTLS_CLIENT_KEY:-}" ]]; then + replay+=(--cert "${MTLS_CLIENT_CERT}" --key "${MTLS_CLIENT_KEY}") + fi + replay+=("${MTLS_URL}") + "${replay[@]}" >"${out}/datapath-replay-status.txt" 2>"${out}/datapath-replay-trace.txt" || true + fi + + return "${rc}" +} + +# wait_for_gateway_policy_reload waits until the gateway has loaded the policy +# revision recorded in the ConfigMap. The gateway runs in a scratch container +# (no shell / cat), so we cannot read the mounted file with kubectl exec. +# Instead we port-forward to the gateway's metrics port and poll +# mcp_gateway_policy_active_revision_info until its "revision" label matches +# the sha256 in the ConfigMap. This confirms: +# 1. kubelet propagated the ConfigMap update to the volume mount, AND +# 2. the gateway's 5-second reload ticker fired and loaded the new policy. +# Deadline is generous because kubelet volume sync jitter dominates on a loaded +# Kind CI node; failure trips the ERR trap → mtls_dump_diagnostics. +wait_for_gateway_policy_reload() { + local deadline=$((SECONDS + 240)) + local pod metrics_port metrics_pf_pid + metrics_port="${MTLS_METRICS_PF_PORT:-19103}" + + pod="$(kubectl get pod -n mcp-servers -l "app=${MTLS_SERVER_NAME}" \ + -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true)" + if [[ -z "${pod}" ]]; then + echo "[mtls] no gateway pod found for app=${MTLS_SERVER_NAME}" >&2 + return 1 + fi + + # Grab the expected revision from the ConfigMap (the value the gateway must + # load). The revision field is the sha256 of the policy document. + local want_rev + want_rev="$(kubectl get configmap "${MTLS_SERVER_NAME}-gateway-policy" -n mcp-servers \ + -o jsonpath='{.data.policy\.json}' 2>/dev/null \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("revision",""))' 2>/dev/null || true)" + if [[ -z "${want_rev}" ]]; then + echo "[mtls] could not read policy revision from ConfigMap; falling back to curl-only sync" >&2 + return 0 + fi + echo "[mtls] waiting for gateway to load policy revision ${want_rev}" >&2 + + kubectl port-forward -n mcp-servers "pod/${pod}" "${metrics_port}:9103" \ + >"${WORKDIR}/mtls-gateway-metrics-pf.log" 2>&1 & + metrics_pf_pid=$! + # Give the port-forward a moment to bind before polling. + local _pf_wait=0 + until curl -sf "http://127.0.0.1:${metrics_port}/metrics" >/dev/null 2>&1 || (( _pf_wait++ >= 10 )); do + sleep 1 + done + + while ((SECONDS < deadline)); do + local active_rev + active_rev="$(curl -sf "http://127.0.0.1:${metrics_port}/metrics" 2>/dev/null \ + | grep '^mcp_gateway_policy_active_revision_info{' \ + | grep -o 'revision="[^"]*"' | cut -d'"' -f2 || true)" + if [[ "${active_rev}" == "${want_rev}" ]]; then + echo "[mtls] gateway loaded policy revision ${want_rev}" >&2 + kill "${metrics_pf_pid}" 2>/dev/null || true + return 0 + fi + sleep 3 + done + + kill "${metrics_pf_pid}" 2>/dev/null || true + echo "[mtls] timed out waiting for gateway to load revision ${want_rev}" >&2 + return 1 +} + +run_e2e_mtls_scenario() { + # Surface server-side diagnostics on any unexpected failure in this scenario. + # errtrace (set -E) propagates the ERR trap into functions and command + # substitutions (e.g. the `$(... adapter enroll ...)` capture) so those + # failures are archived too. This scenario runs last, so scoping is moot. + set -E + trap 'mtls_dump_diagnostics' ERR + + log_line mtls "verifying test-mode workload PKI" + if ! kubectl get clusterissuer mcp-runtime-ca >/dev/null 2>&1; then + echo "expected ClusterIssuer mcp-runtime-ca from test-mode setup" >&2 + exit 1 + fi + kubectl wait --for=condition=Ready clusterissuer/mcp-runtime-ca --timeout=120s >/dev/null + + operator_issuer="$(kubectl get deploy/mcp-runtime-operator-controller-manager -n mcp-runtime \ + -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="MCP_MTLS_CLUSTER_ISSUER")].value}')" + if [[ "${operator_issuer}" != "mcp-runtime-ca" ]]; then + echo "operator MCP_MTLS_CLUSTER_ISSUER = ${operator_issuer:-}, want mcp-runtime-ca" >&2 + exit 1 + fi + + # runtime-api issues adapter/session certificates and reads the issuer from the + # mcp-sentinel-config ConfigMap (via envFrom), so assert it there rather than on + # an inline env var. An empty value makes `adapter enroll` fail with a 503. + runtime_issuer="$(kubectl get configmap mcp-sentinel-config -n mcp-sentinel \ + -o jsonpath='{.data.MCP_MTLS_CLUSTER_ISSUER}' 2>/dev/null || true)" + if [[ "${runtime_issuer}" != "mcp-runtime-ca" ]]; then + echo "mcp-sentinel-config MCP_MTLS_CLUSTER_ISSUER = ${runtime_issuer:-}, want mcp-runtime-ca" >&2 + exit 1 + fi + + log_line mtls "deploying auth.mode mtls MCPServer ${MTLS_SERVER_NAME}" + MTLS_METADATA="${WORKDIR}/mtls-metadata.yaml" + MTLS_MANIFEST="${WORKDIR}/mtls-manifest.yaml" + MTLS_SECRET_NAME="${MTLS_SERVER_NAME}-analytics-creds" + MTLS_IMAGE="registry.registry.svc.cluster.local:5000/${MTLS_SERVER_NAME}:${E2E_WORKLOAD_TAG}" + kubectl create secret generic "${MTLS_SECRET_NAME}" \ + -n mcp-servers \ + --from-literal=api-key="${INGEST_API_KEY}" \ + --dry-run=client -o yaml | kubectl apply -f - + cat > "${MTLS_METADATA}" </dev/null 2>&1; then + run_logged_stage "build mtls MCP server image" ./bin/mcp-runtime server build image "${MTLS_SERVER_NAME}" \ + --metadata-file "${MTLS_METADATA}" \ + --dockerfile "${GO_EXAMPLE_SOURCE_DIR}/Dockerfile" \ + --registry registry.registry.svc.cluster.local:5000 \ + --tag "${E2E_WORKLOAD_TAG}" \ + --context "${GO_EXAMPLE_SOURCE_DIR}" + fi + prune_kind_image "${MTLS_IMAGE}" + load_image_into_kind "${MTLS_IMAGE}" + + run_logged_stage "deploy mtls MCP server manifests" bash -lc \ + "MCP_RUNTIME_CONFIG_DIR=\"${E2E_PIPELINE_CONFIG_DIR}\" ./bin/mcp-runtime server generate --metadata-file \"${MTLS_METADATA}\" --output \"${WORKDIR}/mtls-manifests\" && ./bin/mcp-runtime server --use-kube apply --file \"${WORKDIR}/mtls-manifests/${MTLS_SERVER_NAME}.yaml\"" + wait_for_named_server_ready "${MTLS_SERVER_NAME}" "mcp-servers" 240 + + log_line mtls "waiting for gateway and trust-bundle certificates" + deadline=$((SECONDS + 240)) + while (( SECONDS < deadline )); do + if kubectl get secret "${MTLS_SERVER_NAME}-gateway-mtls" -n mcp-servers >/dev/null 2>&1 \ + && kubectl get secret "${MTLS_SERVER_NAME}-mtls-ca" -n mcp-servers >/dev/null 2>&1; then + break + fi + sleep 2 + done + if ! kubectl get secret "${MTLS_SERVER_NAME}-gateway-mtls" -n mcp-servers >/dev/null 2>&1 \ + || ! kubectl get secret "${MTLS_SERVER_NAME}-mtls-ca" -n mcp-servers >/dev/null 2>&1; then + echo "timed out waiting for gateway TLS secrets (${MTLS_SERVER_NAME}-gateway-mtls and/or ${MTLS_SERVER_NAME}-mtls-ca)" >&2 + exit 1 + fi + + # The mtls datapath only needs Traefik's websecure (TLS) entrypoint; it never + # uses the plaintext web port-forward, so don't gate the scenario on it. + if [[ -z "${TRAEFIK_TLS_PORT_FORWARD_PID:-}" ]]; then + echo "[port-forward] exposing traefik websecure on localhost:${TRAEFIK_TLS_PORT}" + port_forward_bg traefik traefik "${TRAEFIK_TLS_PORT}" 8443 "${WORKDIR}/traefik-tls-port-forward.log" + TRAEFIK_TLS_PORT_FORWARD_PID="${LAST_MANAGED_PID}" + TRAEFIK_TLS_PORT_FORWARD_RESTARTS=0 + fi + wait_port "${TRAEFIK_TLS_PORT}" + + MTLS_INGRESS_PATH="/${MTLS_SERVER_NAME}/mcp" + # Reach the websecure entrypoint over the ingress host so the SNI selects the + # Host()-scoped mtls IngressRoute (and its client-cert TLS options). --resolve + # maps the host to the port-forwarded Traefik on localhost. The caller-facing + # server certificate is Traefik's default (self-signed in test-mode, no default + # TLSStore), so use -k: this scenario verifies the *client* cert mTLS + # enforcement, not the server certificate. + MTLS_URL="https://${MTLS_INGRESS_HOST}:${TRAEFIK_TLS_PORT}${MTLS_INGRESS_PATH}" + MTLS_RESOLVE="${MTLS_INGRESS_HOST}:${TRAEFIK_TLS_PORT}:127.0.0.1" + + wait_for_mtls_traefik_stable "${MTLS_SERVER_NAME}" + # Initial Traefik config reloads (secret-loading retries) may have killed the + # port-forward via broken-pipe. Recover before the first mTLS curl. + recover_traefik_tls_port_forward_if_needed + + log_line mtls "rejecting initialize without a client certificate" + if curl -fsS -k --resolve "${MTLS_RESOLVE}" \ + -H "content-type: application/json" \ + -H "accept: application/json, text/event-stream" \ + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"e2e","version":"1"}}}' \ + "${MTLS_URL}" >/dev/null 2>&1; then + echo "expected initialize without client certificate to fail" >&2 + exit 1 + fi + + ensure_gateway_port_forward + ensure_api_port_forward + if [[ -z "${ADAPTER_PLATFORM_TOKEN:-}" ]]; then + ADAPTER_PLATFORM_TOKEN="$(PLATFORM_ADMIN_EMAIL="${PLATFORM_ADMIN_EMAIL}" PLATFORM_ADMIN_PASSWORD="${PLATFORM_ADMIN_PASSWORD}" python3 -c ' +import json, os +print(json.dumps({"email": os.environ["PLATFORM_ADMIN_EMAIL"], "password": os.environ["PLATFORM_ADMIN_PASSWORD"]})) +' | curl -fsS -X POST \ + -H "content-type: application/json" \ + --data-binary @- \ + "http://127.0.0.1:${API_SERVICE_PORT}/api/v1/auth/login" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')" + fi + + log_line mtls "applying grant and adapter session for mtls datapath" + cat >"${WORKDIR}/mtls-grant.yaml" <&2 + exit 1 + fi + done + + # Traefik reloads TLS config (TLSOption/ServersTransport) once the CA bundle + # secrets appear, briefly closing the websecure listener and breaking the + # port-forward. Recover before the authenticated curl so the connection is live. + recover_traefik_tls_port_forward_if_needed + + log_line mtls "accepting initialize with session-bound client certificate" + # wait_for_gateway_policy_reload confirmed the gateway loaded the new policy. + # Retry a few times as a safety net in case Traefik needs a moment to settle + # its TLS config, or the port-forward needs to be recovered after a TLS alert. + local _mtls_init_try + local init_body="" + for _mtls_init_try in $(seq 1 15); do + init_body="$(curl -sS -k --resolve "${MTLS_RESOLVE}" \ + --cert "${MTLS_CLIENT_CERT}" \ + --key "${MTLS_CLIENT_KEY}" \ + -H "content-type: application/json" \ + -H "accept: application/json, text/event-stream" \ + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"'"${MCP_PROTOCOL_VERSION}"'","capabilities":{},"clientInfo":{"name":"e2e-mtls","version":"1"}}}' \ + "${MTLS_URL}" 2>/dev/null || true)" + if [[ "${init_body}" == *'"result"'* ]]; then + break + fi + echo "[mtls] initialize attempt ${_mtls_init_try}/15 failed: ${init_body:0:120}" >&2 + if [[ ${_mtls_init_try} -eq 15 ]]; then + echo "[mtls] timed out: gateway did not materialize session in policy after 15 attempts" >&2 + false # triggers ERR trap → mtls_dump_diagnostics + fi + recover_traefik_tls_port_forward_if_needed + sleep 2 + done + echo "${init_body}" | python3 -c ' +import json, sys +doc = json.load(sys.stdin) +if doc.get("error"): + raise SystemExit(f"initialize failed: {doc}") +if "result" not in doc: + raise SystemExit(f"missing initialize result: {doc}") +print("mtls initialize ok") +' + + log_line mtls "ignoring spoofed governance headers on mtls path" + # Intentionally omit curl -f: the gateway is expected to reject the spoofed + # headers with a 4xx/5xx, and we still need to capture the response body so the + # Python check below can validate the error instead of being skipped. + spoof_body="$(curl -sS -k --resolve "${MTLS_RESOLVE}" \ + --cert "${MTLS_CLIENT_CERT}" \ + --key "${MTLS_CLIENT_KEY}" \ + -H "content-type: application/json" \ + -H "accept: application/json, text/event-stream" \ + -H "Mcp-Protocol-Version: ${MCP_PROTOCOL_VERSION}" \ + -H "X-MCP-Human-ID: spoofed-human" \ + -H "X-MCP-Agent-ID: spoofed-agent" \ + -H "X-MCP-Agent-Session: definitely-not-${MTLS_SESSION_NAME}" \ + --data '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"aaa-ping","arguments":{}}}' \ + "${MTLS_URL}" 2>/dev/null || true)" + if [[ -n "${spoof_body}" ]]; then + echo "${spoof_body}" | python3 -c ' +import json, sys +doc = json.load(sys.stdin) +err = doc.get("error") or {} +msg = str(err.get("message", "")).lower() +if doc.get("result") and "pong" in json.dumps(doc.get("result")).lower(): + raise SystemExit("spoofed governance headers must not bypass mtls auth") +if err and any(token in msg for token in ("session", "denied", "unauthorized", "forbidden", "grant", "trust")): + raise SystemExit(0) +if err: + raise SystemExit(0) +raise SystemExit(f"unexpected tools/call response with spoofed headers: {doc}") +' + fi +} diff --git a/test/e2e/scenarios_test.sh b/test/e2e/scenarios_test.sh index f9f02a72..bee103bb 100644 --- a/test/e2e/scenarios_test.sh +++ b/test/e2e/scenarios_test.sh @@ -57,6 +57,7 @@ run_valid "api-platform" "api-platform" "api-platform" run_valid "ui-auth" "ui-auth" "ui-auth" run_valid "adapter-proxy" "adapter-proxy" "adapter-proxy" run_valid "cli-platform" "cli-platform" "cli-platform" +run_valid "mtls" "mtls" "mtls" run_valid "observability-with-deps" "smoke-auth,governance,trust,oauth,observability" "smoke-auth,governance,trust,oauth,observability" run_valid "whitespace-trimmed" " smoke-auth , governance " "smoke-auth,governance" run_valid "duplicates-deduped" "smoke-auth,smoke-auth" "smoke-auth" @@ -140,7 +141,8 @@ selector_expect "api" "smoke-auth,api-platform" "services/platform-api/auth/logi selector_expect "runtime-tools-api" "smoke-auth,api-platform,cli-platform" "services/runtime-api/internal/runtimeapi/tools.go" selector_expect "catalog-cli" "smoke-auth,cli-platform" "internal/cli/catalog/catalog.go" selector_expect "adapter" "smoke-auth,adapter-proxy,governance" "internal/cli/adapter/proxy.go" -selector_expect "gateway" "smoke-auth,governance,trust,oauth,adapter-proxy" "services/mcp-gateway/main.go" +selector_expect "mtls-operator" "smoke-auth,mtls" "internal/operator/mtls.go" +selector_expect "gateway" "smoke-auth,governance,trust,oauth,adapter-proxy,mtls" "services/mcp-gateway/main.go" selector_expect "observability" "smoke-auth,governance,trust,oauth,observability" "services/ingest/main.go" selector_expect "broad" "all" "api/v1alpha1/mcpserver_types.go" diff --git a/test/e2e/select_pr_scenarios.sh b/test/e2e/select_pr_scenarios.sh index 0a36e1ed..dfac3c5f 100755 --- a/test/e2e/select_pr_scenarios.sh +++ b/test/e2e/select_pr_scenarios.sh @@ -50,6 +50,10 @@ classify_path() { mark_all return ;; + internal/operator/mtls*|internal/cli/certmanager/*|traefik-plugins/spiffe-identity/*|config/cert-manager/*|pkg/identity/*|pkg/certauth/*|test/e2e/scenarios/mtls.sh) + add_scenario "mtls" + return + ;; api/*|cmd/operator/*|internal/operator/*|config/*|k8s/*|pkg/controlplane/*|pkg/k8sclient/*|pkg/kubeworkload/*|pkg/manifest/*|pkg/metadata/*) mark_all return @@ -108,6 +112,7 @@ classify_path() { add_scenario "trust" add_scenario "oauth" add_scenario "adapter-proxy" + add_scenario "mtls" return ;; services/ingest/*|services/processor/*|pkg/clickhouse/*|pkg/events/*|pkg/sentinel/*|pkg/serviceutil/*)