Hive Hive
Sign in

feat(kura): serve HTTP and REAPI gRPC on a single co-hosted listener

GitHub issue · Closed

Metadata
Source
tuist/tuist #11591
Updated
Jul 5, 2026
Domains
Kura
Details

What & why

Kura used to serve its HTTP cache API and its REAPI gRPC surface on two separate listeners (container ports 4000 and 50051). That’s invisible behind the public ingress, where nginx co-hosts both protocols on :443 via path-based backend routing — but the private runner-cache data plane (NodePort / cluster-DNS) bypasses nginx and exposes the raw ports, while dispatch hands a runner a single cache endpoint (TUIST_CACHE_ENDPOINT). tuist bazel setup derives its gRPC target straight from that URL’s host:port, so its REAPI GetCapabilities handshake landed on the plain-HTTP listener and returned “Unexpected non-200 HTTP Status Code”.bazelrc.tuist was never written → runner builds fell back to a cold cache.

This PR makes “one endpoint speaks both protocols” the only model Kura has, in the runtime and in every deployment surface:

  • KURA_PORT (plaintext, default 4000) — one listener serving the HTTP cache API and h2c REAPI gRPC, dispatched by path.
  • KURA_HTTPS_PORT (default 4443, bound only when KURA_PUBLIC_TLS_CERT_PATH/KURA_PUBLIC_TLS_KEY_PATH are set) — the same co-hosted surface over TLS, ALPN-negotiated (h2 for gRPC, http/1.1 for HTTP).
  • Removed: the dedicated gRPC listener, KURA_GRPC_PORT and KURA_GRPC_TLS_CERT_PATH/KURA_GRPC_TLS_KEY_PATH as runtime config, and port 50051 everywhere in the deployment wiring (Services, NodePorts, NetworkPolicies, firewall carve-outs, tailscale ACLs, docs).

We chose in-process co-hosting over the alternatives (an nginx sidecar per Kura pod, or a Cilium CiliumEnvoyConfig L7 route) because it keeps Kura’s zero-copy artifact-serving path in the data plane — no extra proxy hop — and because HTTP and gRPC are equally important here, so neither should pay a proxy tax.

How it works (runtime)

  • Routing by path. tonic 0.14’s Routes is itself an axum::Router mounting each gRPC service at /{service}/{*rest}; those paths never collide with the HTTP cache routes, so cohosted_router() merges the REAPI routes into public_router. The gRPC request-accounting layer rides along, so gRPC shows up in inflight/latency metrics and counts toward the shutdown drain.
  • Fallback semantics correct for both protocols. tonic’s router carries a fallback answering any unmatched path with HTTP 200 + grpc-status: Unimplemented, and Router::merge adopts it — which would leak 200s onto the plain-HTTP surface (/_internal/status on the public port must 404; the peer-mTLS e2e spec asserts it). The merged router overrides the fallback with a protocol-aware one: application/grpc* requests get tonic’s exact Unimplemented response, everything else gets 404.
  • No HTTP performance loss. The plaintext listener runs through accelerated_file_serving::serve_public_http, so HTTP/1 artifact GETs keep the sendfile/splice kernel zero-copy fast path. classify_route only accelerates HTTP/1.1 GETs on Linux; gRPC (h2c POST), HTTP/2, and TLS fall through to hyper.
  • No gRPC performance loss. The hyper builder advertises fixed REAPI-sized HTTP/2 windows (4 MiB stream / 16 MiB connection). Fixed, never adaptive, deliberately: hyper’s adaptive_window overrides the fixed windows and ramps a single stream from ~64 KiB via BDP estimation, which halves lone REAPI uploads under WAN latency (measured 9.84 vs 20.65 MB/s at 100 ms RTT; fixed windows measure at parity, 21.43 vs 20.30 MB/s).
  • gRPC-grade connection behavior on the shared transport. Every accept path sets TCP_NODELAY (chatty unary REAPI calls like FindMissingBlobs are latency-bound; Nagle + delayed ACK stalls them). The hyper path recycles connections after CONNECTION_MAX_AGE (300s) with a GOAWAY and a 900s grace for in-flight streams — without recycling, a long-lived Bazel channel pins to a demoted-but-alive NodePort primary indefinitely after failover. Draining sends the same GOAWAY to established HTTP/2 connections and stops HTTP/1 keep-alive reuse, so rolling deploys hand connections off gracefully inside the drain budget.

Deployment wiring (hard cutover, by design)

Every deployment surface knows only the single co-hosted port:

  • kura-controller renders one http containerPort, one http Service/NodePort port. status.nodePortGRPC is gone from the CRD (dispatch only ever consumed nodePortHTTP — a single URL is exactly what the co-hosted listener makes correct). The public gRPC Ingress remains but backends the http Service port: it exists solely so ingress-nginx renders the REAPI path prefixes with grpc_pass (backend-protocol: GRPC) instead of proxy_pass; public grpcs:// dial addresses don’t change.
  • One transitional exception: the controller still sets KURA_GRPC_PORT=50051 in the pod env. Images at or below the last dedicated-listener release hard-require it and would crash-loop without it — taking the HTTP cache down, not just gRPC — the moment the controller rewrites their pod template on its reconcile loop. With the env present, a still-pinned instance starts cleanly and only loses in-cluster gRPC reachability (no Service port routes there), which is the accepted blast radius: Kura is a cache and Tuist is its only operator. Co-hosted images ignore the variable; a follow-up removes it once the fleet is fully past the floor.
  • Standalone Helm chart: single http port through statefulset/services/ingress; service.grpcPort value removed; the chart requires a co-hosted Kura image (releases after kura@0.10.15).
  • Network surfaces (runner-namespace NetworkPolicy, macOS VM-egress pf rules, tailscale ACL grants, capi provider flag): carve-outs are port 4000 only.
  • Self-host: compose, .env.example, and guides configure one cache port; the TLS table documents KURA_PUBLIC_TLS_*/KURA_HTTPS_PORT.
  • Rollout sequencing: a controller deploy paired with a pre-cohosted runtime image (or the reverse) leaves gRPC unreachable until both sides land — accepted for a cache. Staging deploys both from the same commit; for canary/prod, cut the kura release together with the server release.

User / developer impact

  • Runner-cache builds (tuist bazel setup behind TUIST_CACHE_ENDPOINT) get a working REAPI target from the single URL — the original bug.
  • Public CLI/Bazel users keep their dial addresses: same host, :443, same TLS; only the ingress→pod hop changes.
  • Self-hosters upgrading past kura@0.10.15 must drop KURA_GRPC_PORT/KURA_GRPC_TLS_* and point gRPC clients at the cache port (4000 by default).

Validation

  • Unit: 279 Rust lib tests. Co-hosted-listener tests for plaintext and TLS (self-signed cert through the real build_public_rustls_config path) assert HTTP /up, REAPI GetCapabilities over h2c, 404 for unmatched HTTP paths, and grpc-status: Unimplemented for unknown gRPC services; a transport test drives a live h2c connection through a drain and asserts both ends observe a clean GOAWAY close. cargo clippy -D warnings and the Bazel gates (mise run clippy, mise run test-unit) pass.
  • E2E (shellspec, all through the co-hosted kura:4000): clients_spec (Bazel remote cache + Buck2 REAPI), accelerated_serving_spec (sendfile fast path), mtls_spec (internal-endpoint 404 + peer replication), rollout_spec (drain cycle + writer fencing) — all green.
  • Throughput harness (test/e2e/grpc-upload-throughput, toxiproxy @ 100 ms RTT, 16 MB): direct co-hosted uploads at parity with a dedicated tonic listener; nginx window comparison unchanged.
  • Deployment: kura-controller go test/vet/gofmt (incl. the transitional-env assertion), helm lint + template for both charts, full kura chart render passes a server-side dry-run against a real apiserver, macos-host-bootstrap and capi provider builds/tests, acls.json parses, repo-wide 50051/KURA_GRPC sweep leaves only the transitional env var and its docs/tests.
  • README, docs/architecture.md, and infra/kura-controller/AGENTS.md updated to the final model.

🤖 Generated with Claude Code

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments

No GitHub comments yet.