|
| 1 | +# PEM direct-query gRPC endpoint — contract |
| 2 | + |
| 3 | +## Why |
| 4 | + |
| 5 | +Today in-cluster services read PEM data by querying the **vizier-query-broker** |
| 6 | +(the standard `ExecuteScript` path). That works and is the primary path. This |
| 7 | +feature adds the *node-local* alternative: make the normal `vizier-pem` itself |
| 8 | +serve `ExecuteScript` directly over gRPC, so an on-node client can query its |
| 9 | +node-local PEM with no broker hop and no cloud dependency. |
| 10 | + |
| 11 | +This capability was already proved in the experimental `standalone_pem` |
| 12 | +(`src/experimental/standalone_pem/vizier_server.h` — `px::vizier::agent::VizierServer` |
| 13 | +implementing `api::vizierpb::VizierService::ExecuteScript` against a local Carnot). |
| 14 | +The two differences for the real PEM: |
| 15 | + |
| 16 | +1. **Metadata-connected.** The normal PEM has the metadata service, so per-pod PxL |
| 17 | + filters (`df[df.ctx['pod'] == ...]`) resolve — the gap that made standalone_pem |
| 18 | + return empty per-pod results. Reuse the PEM's existing Carnot + |
| 19 | + table_store + metadata state; do **not** stand up a second Carnot. |
| 20 | +2. **Authenticated.** standalone_pem was insecure (`WithDirectCredsInsecure`). The |
| 21 | + real PEM is in `pl` and must require a **valid cluster service JWT** (the same |
| 22 | + `jwt-signing-key` used by kelvin, query-broker, and metadata-server). |
| 23 | + |
| 24 | +## The endpoint |
| 25 | + |
| 26 | +- Service: `px.api.vizierpb.VizierService` (the generated gRPC service). |
| 27 | +- Method implemented: **`ExecuteScript`** (server-streaming). Mutations/tracepoints |
| 28 | + are **out of scope** — return `UNIMPLEMENTED` for `req.mutation()==true`. |
| 29 | + (standalone_pem handles mutations; the read path never mutates.) |
| 30 | +- Transport: gRPC over TLS using the in-cluster self-signed CA |
| 31 | + (`SSL::DefaultGRPCServerCreds()`). Insecure fallback only when `PL_DISABLE_SSL=1` |
| 32 | + is explicitly set for a dev/soak cluster. |
| 33 | + |
| 34 | +## Config (flags / env) — gated OFF by default |
| 35 | + |
| 36 | +| flag / env | default | meaning | |
| 37 | +|-------------------------------------|---------|----------------------------------------------------| |
| 38 | +| `--direct_query_enabled` / `PL_PEM_DIRECT_QUERY_ENABLED` | `false` | master switch; when false the port is never opened | |
| 39 | +| `--direct_query_port` / `PL_PEM_DIRECT_QUERY_PORT` | `50305` | gRPC listen port for the direct-query service | |
| 40 | +| `--direct_query_jwt_signing_key` / `PL_JWT_SIGNING_KEY` | `""` | HMAC key the bearer JWT must verify against | |
| 41 | + |
| 42 | +Default-off so existing PEM deployments are byte-for-byte unchanged until opted in. |
| 43 | +Opt out at runtime with `PL_PEM_DIRECT_QUERY_ENABLED=false`, or compile it out with |
| 44 | +`--//src/vizier/services/agent/pem:direct_query=disabled`. |
| 45 | + |
| 46 | +## Auth contract |
| 47 | + |
| 48 | +Every `ExecuteScript` call MUST present `authorization: Bearer <jwt>` metadata. |
| 49 | +The JWT is verified with `PL_JWT_SIGNING_KEY` and must: |
| 50 | +- have a valid signature (HS256) against the signing key, |
| 51 | +- be unexpired (`exp` in the future), |
| 52 | +- carry a service/audience claim acceptable to vizier |
| 53 | + (minted via `GenerateJWTForService` in `src/shared/services/utils`). |
| 54 | + |
| 55 | +Missing/invalid/expired token → `grpc::StatusCode::UNAUTHENTICATED`. No token must |
| 56 | +ever fall through to query execution. |
| 57 | + |
| 58 | +## Behavioral contract (the executable spec → `direct_query_server_test.cc`) |
| 59 | + |
| 60 | +1. **flag-off → no listener.** With `direct_query_enabled=false`, nothing listens on |
| 61 | + the port; the PEM starts exactly as today. |
| 62 | +2. **flag-on → serves ExecuteScript.** With it enabled + a signing key set, a gRPC |
| 63 | + client with a valid bearer JWT gets a streamed response (status OK) for a trivial |
| 64 | + PxL (e.g. `import px; px.display(px.DataFrame('http_events'))`). |
| 65 | +3. **auth required.** Same call with (a) no token, (b) a token signed by the wrong |
| 66 | + key, (c) an expired token → each `UNAUTHENTICATED`, no rows. |
| 67 | +4. **metadata-connected filter.** A PxL with a per-pod filter returns only that pod's |
| 68 | + rows (proves the metadata gap from standalone_pem is closed on the real PEM). May |
| 69 | + be an integration test tagged `requires_metadata` if a unit Carnot fixture can't |
| 70 | + supply pod context. |
| 71 | +5. **mutation rejected.** `req.mutation()==true` → `UNIMPLEMENTED` (scope guard). |
| 72 | +6. **no regression.** The existing PEM agent registration / Carnot / Stirling path is |
| 73 | + unchanged when the flag is off (assert via the existing PEM smoke/unit tests). |
| 74 | + |
| 75 | +## Client integration (informational) |
| 76 | + |
| 77 | +The existing in-cluster clients already have the pieces: |
| 78 | +- **JWT mint**: `GenerateJWTForService` in `src/shared/services/utils/jwt.go`; mount |
| 79 | + the `pl-cluster-secrets/jwt-signing-key` via `secretKeyRef`. |
| 80 | +- **gRPC metadata**: attach `authorization: Bearer <jwt>` to each call. |
| 81 | +- **Address**: `<HOST_IP>:50305` (HOST_IP via downward API, or the pod's node IP). |
| 82 | +- **TLS**: use `WithDisableTLSVerification` against the cluster's self-signed CA, |
| 83 | + matching the broker path. |
| 84 | + |
| 85 | +## Done = |
| 86 | + |
| 87 | +`direct_query_server_test.cc` green under `bazel test`, PEM image builds via the |
| 88 | +vizier-release workflow, and a live cluster shows an on-node client ruling in an |
| 89 | +e2e scenario off the node-local PEM with the same verdict it gets via the broker. |
0 commit comments