Kubernetes

The OpenEng Kubernetes app is a full cluster console — dashboard, resource explorer, topology, nodes, an interactive pod terminal, and live events — split across two halves that trust each other and almost nothing else: a local sealed engine bound to 127.0.0.1:50054 that does all cluster access, and a thin web UI at k8s.openeng.app that only renders panels and issues operations. Your kube credentials — tokens, client certs, exec-credential plugins — are resolved entirely inside the engine, on your machine, from your own kubeconfig. They never cross the wire to the browser, and they never egress to OpenEng.

Overview

The browser cannot reach a Kubernetes API server directly, and you would not want it to hold your cluster credentials if it could. The app resolves that by keeping the two concerns physically apart:

The engine (local)

A native binary that binds loopback 127.0.0.1:50054. It reads ~/.kube/config plus any imported fragments, builds an authenticated client per context using the kube and k8s-openapi crates (no kubectl shell-out), and executes every operation — list, get, apply, logs, exec, metrics — against the API server itself. Credential resolution happens here and only here.

The UI (browser)

A static single-page app served from k8s.openeng.app. It is a pure client: it renders the clusters / resources / pods / logs / events / helm / metrics / nodes panels and sends gRPC-Web calls to the local engine. It never sees a kube token; the only cluster data it receives is your own — resource YAML, log lines, exec bytes.

Every call between them travels the same single encrypted boundary. The transport is gRPC + gRPC-Web over loopback HTTPS, with each frame sealed under a per-session key exchanged in CreateSession (the client sends client_pub, the engine returns engine_pub; every subsequent RPC is sealed per-frame). A default-deny auth interceptor guards the port, and the browser pairs by fetching a 0600 handshake token the engine mints at boot. The security properties are covered under Security model and engine updates.

Note One engine per machine. The engine holds a spawn lock, advertises its endpoint only after a successful bind, and serves every browser on the host from the same local state — so preferences, caches, and the current context are consistent no matter which browser you open.

Connections and clusters

The engine presents a single flat list of clusters via ListClusters, blending two sources into one uniform Cluster row. The active one is the current context; you switch with SetContext and the header cluster picker.

Kubeconfig contexts

Every context in your merged kubeconfig (~/.kube/config plus imported fragments) becomes a row. These are backed by a real local kube::Client: the engine resolves the context's token / cert / exec-credential locally when it first builds and caches the client. Full capability — watch, port-forward, helm, drain — is available.

Connector clusters

A cluster whose source is an HTTPS connector rather than a local client, identified by the context id connector:<name>. The engine fetches resources by POSTing tool calls to the connector instead of talking to an API server itself. Ideal for managed clusters reachable only from inside a VPC. See Cloud clusters via connector.

Switching and connecting

The active context is URL-addressable: ?ctx= in the UI drives it, and the header switcher writes it. Switching to a context runs a small pipeline — SetContext, then ConnectCluster (which builds/validates the client, or for a connector calls cluster_info), then discovery (ListNamespaces + ListResourceKinds). The relevant RPCs:

ListClusters      list every kubeconfig context + connector cluster
GetContext        the engine's current context
SetContext        switch the active context (validated against known contexts)
ConnectCluster    build + verify the client; returns the API server version
ClusterHealth     api reachable? server version, nodes ready / total

Both kinds switch identically from the UI's point of view — the header picker, the Settings clusters list, and a deep-linked ?ctx all funnel through the same switch.

Importing a kubeconfig

You do not have to depend on whatever is already in ~/.kube/config. In Settings → Clusters, paste a kubeconfig YAML with a name and click Add kubeconfig; the UI calls AddKubeconfig. The engine:

  1. Parses the YAML and rejects anything with no contexts (or invalid syntax).
  2. Writes the fragment 0600 under ~/.openeng/kubernetes/kubeconfigs/ so it survives restarts — the file may carry credentials, so it is never logged.
  3. Merges it into the live registry and returns the newly available context names, which appear immediately in ListClusters.
Tip On boot the engine folds every imported fragment on top of ~/.kube/config. A broken fragment is skipped rather than fatal, so one bad paste never stops the engine from serving your other clusters.

Cloud clusters via connector

A connector cluster is a cluster reached through an OpenEng cloud-Kubernetes connector — a deployed service (e.g. a Lambda) that runs inside your own cloud/VPC, speaks the OpenEng tools protocol over HTTPS, and reaches the API server from there. The engine never holds the cluster's credentials; it only holds your key to your connector, and it sees only the connector's trimmed JSON responses.

Adding one

Settings → Clusters → Add a cloud cluster calls AddConnectorCluster. The form fields map directly onto the request:

FieldWire fieldMeaning
NamenameAssigned the context id connector:<name>.
Connector URLurlThe connector base URL (must start with http:// or https://; trailing slashes trimmed).
API keyapi_keyYour connector key, sent as the x-api-key header.
Auth token (optional)auth_tokenAn explicit Authorization: Bearer value. Empty ⇒ the API key is reused as the bearer.
Write key (optional)write_keyA plaintext write key sent as x-write-key. Empty ⇒ read-only. See Scopes.

On add, the engine probes the connector's GET /manifest to (a) validate reachability and the credentials and (b) detect whether the connector is serving read-only. It then persists the connector 0600 under ~/.openeng/kubernetes/connectors/ and returns the assigned context plus the detected read_only flag. A 401/403 comes back stamped with the CLUSTER_UNAUTHORIZED sentinel so the UI can say "check the API key" rather than "unreachable".

How reads are served

For a connector context the engine routes each operation to a connector tool over POST /call-stream (NDJSON), reducing the streamed frames back into one result for unary RPCs. An older connector that predates streaming (which 404s /call-stream) is transparently retried over the buffered POST /call, so a not-yet-redeployed connector is never broken. A connector exposes no discovery API, so the engine advertises a static set of common kinds for the Explorer.

ListNamespaces  -> list_namespaces        ConnectCluster/Health -> cluster_info
ListResources   -> list_<plural>          GetResource/Describe  -> get_<kind>
ListPods        -> list_pods              GetPodLogs (stream)   -> pod_logs
ListNodes       -> list_nodes             GetMetrics            -> node_metrics | pod_metrics
ExecPod         -> pod_exec (per-command) ApplyResource         -> apply_manifest
DeleteResource  -> delete_resource        Cordon/Uncordon       -> node_cordon | node_uncordon

Re-authentication

When a connector later rejects the stored credentials mid-session (any RPC returning CLUSTER_UNAUTHORIZED), the UI raises a re-auth dialog — but only if that connector is still the active cluster, so a stale late 401 from a cluster you just left cannot pop a dialog. GetConnector reads the stored api_key, auth_token, and write_key back to the local UI to pre-fill the dialog; you edit, save, and retry. That echo stays strictly on the loopback seal boundary — the same trust domain that entered the values — and echoing the write key back means a re-auth save never silently drops your write access. Remove a connector with RemoveConnectorCluster (it deletes the persisted file).

Scopes: read-only, write, and gating

Local kubeconfig clusters carry no OpenEng-level scope — whatever your kube RBAC allows, the engine allows; RBAC is your authority. Connector clusters, by contrast, have an explicit read-only / write scope enforced at two layers.

Read-only by default

A connector added with no write key attaches read-only. The engine's guard_read_only hook refuses every mutation routed to a read-only connector with permission_denied ("cluster is read-only") — ApplyResource, DeleteResource, CordonNode, UncordonNode. The connector itself also hides and blocks mutating tools, so the guard is defense in depth, not the only line.

Write via the write key

Supply a write key and the engine sends it as x-write-key; the connector grants write access when it matches its configured WRITE_KEY hash. The Settings row then shows a read-write badge instead of read-only. Generate the key at connectors.openeng.app → Write key.

Live read-only re-probe

Read-only status is not frozen at add time. Every ListClusters re-probes each connector's /manifest, so a connector that flips read-only ⇄ write (e.g. its env changed) takes effect on the next refresh — the UI enables or disables mutating actions immediately, with no re-add.

Force-exec prefixes

A read-only connector can still be allowed to exec into specific pods via its K8S_FORCE_EXEC allowlist — a set of pod-name prefixes surfaced on each cluster row as force_exec (refreshed on every ListClusters manifest re-probe). The UI uses these to decide, per pod, whether to offer an exec console even on a read-only cluster. Because the connector enforces the allowlist itself and refuses non-matching pods, the engine deliberately does not apply guard_read_only to connector exec — a refused pod comes back as an error in PtyDone.status.

Note Metrics is always a read and is never gated by read-only, on either kind of cluster.

The workspace

Once a cluster is active, the console is organised into sections; each (except Settings) requires a connected context and otherwise prompts you to pick one.

Overview

The dashboard — cluster health, node readiness, namespace rollups, recent rollouts, top consumers, and metric sparklines.

Explorer

The generic resource browser: pick a kind and namespace, list rows, and open the detail drawer for YAML / describe / edit / delete.

Topology

A visual map of workloads and their relationships across the selected scope.

Nodes

Node inventory with readiness, roles, kubelet version, capacity, taints, and cordon / uncordon / drain actions.

Terminal

The pod exec console and per-pod interactive shell.

Events

A live stream of cluster events (Normal / Warning) for the current namespace scope.

Settings is reachable without a connected cluster — it hosts the local engine status and the clusters management (kubeconfig import + connector add / switch / remove). The current context (?ctx) and namespace (?ns) are carried across sections in the URL; per-view state like the open drawer is dropped when you navigate.

Namespaces and resources

Discovery is two RPCs: ListNamespaces for the namespace switcher and ListResourceKinds for the kind picker (group / version / kind / plural / namespaced). A local cluster enumerates kinds from the API server's discovery; a connector advertises a static common set.

Generic resource operations

Everything in the Explorer runs through a small, kind-agnostic set of RPCs that work for any kind via the dynamic API:

RPCReturnsPurpose
ListResourcessummariesRows for a kind in a namespace: status, humanised age, and kind-specific columns (Ready, Restarts, IP, …).
GetResourcefull YAMLThe complete object manifest, for viewing and editing.
DescribeResourcepretty textA kubectl describe-style rendering with status, conditions, and events.
ApplyResourceokServer-side apply from edited YAML (write-gated on connectors).
DeleteResourceokDelete by kind / api-version / name / namespace (write-gated on connectors).
WatchResourcesstreamLive table updates: ADDED / MODIFIED / DELETED events feed the row list.
Watch is local-only WatchResources is served through a local kube::Client and is not routed to connector tools. On a connector cluster the resource tables refresh by re-listing rather than by a live watch stream.

Pods, logs, and exec

ListPods returns the compact pod shape — phase, node, IP, restarts, ready/total, age, containers — for the pods pane. Two richer surfaces sit on top.

Streaming logs

GetPodLogs streams LogEvents. It takes container, follow (tail), previous (the crashed container's prior instance), tail_lines, and since (e.g. 1h, 10m). On a connector the logs stream over /call-stream, forwarding each NDJSON text chunk into the same LogEvent stream as it arrives, with the same tail semantics.

Interactive exec

The exec surface mirrors the Terminal engine's PTY shape: a server stream of PtyEvent (started / output / done) for output, and unary RPCs for input — WriteExec, ResizeExec, CloseExec. There are two execution modes depending on the cluster kind:

Local: a live PTY

ExecPod opens a real interactive PTY into the container over the kube client. The container's own shell handles line editing and its own tab-completion. Full-screen programs, cursor keys, and Ctrl-C behave as expected.

Connector: per-command exec

The connector transport is stateless per request, so instead of a live shell the engine runs each command via the connector's pod_exec and streams its output. It round-trips the shell state (cwd + env) the connector returns back into the next command — keyed per (context, namespace, pod, container) — so cd and export persist command to command. The resulting cwd is surfaced as PtyDone.cwd and rendered in the prompt.

Tab-completion for the connector console

Because a connector console has no live shell to complete paths, the engine serves completion explicitly via CompleteExec. Given the input line up to the cursor it resolves the trailing path token against the pod's filesystem with a single ls run under the session's stored cwd/env — deliberately without advancing that state (completion must never mutate the session it inspects). It returns the directory's full listing (dirs end in /, which the UI caches and filters by prefix), the fragment being completed, and the current cwd. A first bare word is treated as a command name and completed by the UI from its own catalog + history. Failures come back as a message, never a hard error — a completion that can't answer is a no-op at the prompt, not a broken console.

Port-forwarding and events

Port-forward

StartPortForward opens a local listener that tunnels local_port → pod_port through the engine; ListPortForwards enumerates active tunnels and StopPortForward tears one down. Port-forward is served through a local kube::Client, so it is available on kubeconfig clusters only.

Events

WatchEvents streams cluster Events for a namespace — type (Normal / Warning), reason, message, involved object, last-seen, and count — feeding the Events pane and the Overview's recent-activity views. Like watch and port-forward, the events stream runs through the local client and is not routed to connectors.

Helm releases

The Helm pane manages releases through four RPCs: HelmList, HelmInstall and HelmUpgrade (each a ProgressEvent stream of live output lines), and HelmUninstall. Install/upgrade take release, chart, version, and values; uninstall takes an optional keep_history.

Requires a local helm binary The shipped engine drives Helm by shelling out to a helm binary resolved on PATH, streaming its stdout/stderr as progress. If helm is absent the operation returns a clean "helm binary not found on PATH" rather than failing opaquely. Helm operations run against local kubeconfig contexts only — they are not routed to connector clusters.

Nodes and metrics

Nodes

ListNodes returns each node's readiness, schedulability, roles, kubelet version, age, CPU/memory capacity, taints, and conditions. Node operations:

  • CordonNode / UncordonNode — mark a node un/schedulable. Routed to node_cordon / node_uncordon on a connector, and write-gated by guard_read_only there.
  • DrainNode — evict pods, streaming progress. Served through the local client only (kubeconfig clusters).

Metrics

GetMetrics takes a scope of node or pod and returns CPU cores, memory bytes, and their percentages per entity — powering the Overview sparklines, top-consumers, and node-capacity views. On a local cluster it reads the metrics API (a metrics-server must be present); on a connector it routes to node_metrics / pod_metrics. Metrics is a read and is never read-only-gated.

Local vs connector capability matrix

Because a connector reaches your cluster through a tool API rather than a live client, a handful of streaming/tunnelling operations are available only on kubeconfig clusters. The full picture:

CapabilityKubeconfig contextConnector cluster
Namespaces, kinds, resource list/get/describeYesYes (static kind set)
Apply / DeleteYes (RBAC)Yes, if read-write
Live resource watch (WatchResources)YesNo — re-list instead
Pods list & streaming logsYesYes
Pod execInteractive live PTYPer-command (state round-trip + CompleteExec)
Port-forwardYesNo
Events (WatchEvents)YesNo
HelmYes (needs helm on PATH)No
Nodes list, cordon / uncordonYesYes (cordon/uncordon write-gated)
Drain nodeYesNo
MetricsYes (metrics-server)Yes

Reusing a cloud cluster in Chat

A connector cluster you add here is not private to this app. Because connectors are persisted by the engine under ~/.openeng/kubernetes/connectors/ and exposed through the standard ListClusters RPC, the OpenEng Chat app's coding agent can drive the very same cluster — without ever re-entering the credentials.

Chat's engine opens its own sealed loopback session to the Kubernetes engine (the same 127.0.0.1:50054, addressed as engine.openeng.app:50054 pinned to loopback, SNI + Host engine.openeng.app) and calls the typed RPCs on your behalf: ListClusters, ListNamespaces, ListPods, DescribeResource, GetPodLogs, ListResources, and so on. A Chat group's config carries a connection_id that is exactly the Kubernetes context id — a plain context like prod, or a connector like connector:my-cloud — and an empty id means the K8s engine's current context.

The key property: the connector's API key, bearer, and write key never enter Chat. Chat asks the Kubernetes engine by context id; the Kubernetes engine is the sole holder of those credentials and performs the connector call itself. The read-only / write scope you configured here is enforced there too — if the connector is read-only, the agent's mutations are refused the same way the UI's are.

Tip Add and validate a cloud cluster once, in this app's Settings. It then shows up automatically in Chat's connection picker, already scoped, already authenticated.

Security model and engine updates

The no-egress invariant

The proto that defines this boundary has a hard rule, enforced by a schema test: no field may carry a secret, credential, API key, password, private key, or token. Cluster auth is resolved locally by kube from your kubeconfig and never crosses the wire. The legitimate payload is only your own cluster data — resource YAML, logs, exec bytes.

There is exactly one deliberate exception: a connector cluster's api_key / auth_token / write_key, explicitly marked connector-credential and exempted from the egress test. That is your key for your connector — handed to the engine once on AddConnectorCluster, stored locally 0600, and only ever echoed back to the local UI (over the loopback seal) to pre-fill the re-auth dialog. It is not a product secret leaking out.

Boundary hardening

Sealed loopback HTTPS

Every frame after CreateSession is sealed under the per-session key. The engine serves TLS on loopback using the publicly-trusted engine.openeng.app certificate (DNS-pinned to 127.0.0.1) so an https:// web app avoids Mixed-Content blocking, with a persisted self-signed cert as fallback.

Origin + Host allowlists

CORS is not a wildcard: only https://k8s.openeng.app (and localhost in dev) may reach the engine from a browser, and the allowed origin is reflected, not starred. A separate Host/authority allowlist (engine.openeng.app, localhost, 127.0.0.1, ::1) defeats DNS rebinding — a request whose Host is an attacker domain is rejected outright even if the Origin trick would make it look same-origin.

Default-deny auth

The port sits behind a default-deny interceptor. The browser pairs by fetching a 0600 handshake token the engine mints at boot from OS randomness (vended at an auth-exempt GET /openeng/handshake); a world-readable token is refused. Private Network Access preflights are answered so the public app can legitimately reach the loopback engine.

Local-only persistence

Imported kubeconfigs (~/.openeng/kubernetes/kubeconfigs/) and connectors (~/.openeng/kubernetes/connectors/) are written 0600 and never logged. Generic UI preferences and SWR caches are engine-persisted (GetClientState/SetClientState, CacheGet/CacheSet) so every browser on the machine shares the same last-known state — never as a credential carrier.

Mandatory engine auto-update

The engine self-updates at boot and cannot be skipped. While it is serving, a newer stable build is downloaded, verified, and staged in the background; CreateSession and GetUpdateStatus report the running version plus any staged_version awaiting a restart. The UI surfaces a "restart to update" banner that calls RestartEngine — the engine re-execs in place into the staged binary, carrying the live session internally so you never re-pair, and the UI re-probes and reconnects to the new version. No credential rides either update RPC; they carry only version strings.