Skip to content

feat(kubernetes): add proxy-pod supervisor topology - #2885

Open
russellb wants to merge 48 commits into
NVIDIA:mainfrom
russellb:feat/kubernetes-proxy-pod-topology
Open

feat(kubernetes): add proxy-pod supervisor topology#2885
russellb wants to merge 48 commits into
NVIDIA:mainfrom
russellb:feat/kubernetes-proxy-pod-topology

Conversation

@russellb

@russellb russellb commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the Kubernetes proxy-pod supervisor topology: network enforcement and
gateway forwarding move out of the sandbox pod entirely and into a paired,
per-sandbox supervisor Deployment. The sandbox pod runs the agent image
directly — no supervisor binary, no gateway credentials, no privileged init
container, no shared process namespace. Egress is fenced by two per-sandbox
Kubernetes NetworkPolicy objects rather than by pod-local nftables rules.

The result is the least-privileged sandbox pod any OpenShell topology produces:
runAsNonRoot, all Linux capabilities dropped, no added privilege at any layer.
That is what makes it the first topology to run on OpenShift under a stock,
Red Hat-shipped SCC (nonroot-v2) with no bespoke security grant.

This continues @TaylorMutch's original proxy-pod work in #2077 (and the
earlier #2016), rebased onto current main with the correctness,
OpenShift-enablement, readiness, and workload-command work needed to make the
topology usable. It is the sibling of the cni-sidecar topology in #2606.

Topology tradeoffs (combined / sidecar / cni-sidecar / proxy-pod)

The topologies trade privilege against capability. The dividing line for
proxy-pod is consistent: everything enforceable or observable at the network
boundary
is retained, and everything requiring visibility inside the
workload's namespaces
is given up.

In every topology the network supervisor (the OpenShell proxy) is the egress
policy engine — it evaluates L4/L7 network policy, terminates TLS for L7
inspection, injects credentials, and makes the policy-approved upstream
connections. The topologies differ in how the workload is confined to that
proxy
so it cannot route around it, and — for proxy-pod — in where the
supervisor runs
. In proxy-pod the supervisor is a separate per-sandbox pod
and the confinement is a Kubernetes NetworkPolicy pair: an agent-egress
policy that lets the workload pod reach only the supervisor (plus cluster DNS),
and a supervisor-ingress policy that lets only the paired workload reach the
supervisor's proxy port. The in-pod topologies enforce that confinement with
node-local nftables/CNI rules, so it holds regardless of CNI; proxy-pod relies
entirely on CNI NetworkPolicy enforcement (see the Requires NetworkPolicy
enforcement
row), which is why a non-enforcing CNI is its highest-severity risk.

Dimension combined (default) sidecar cni-sidecar proxy-pod
Egress policy engine in-pod network supervisor in-pod network supervisor in-pod network supervisor separate supervisor pod
Confines workload to the proxy via in-pod nftables in-pod nftables node CNI rules Kubernetes NetworkPolicy
Network + L7 policy yes yes yes yes
Filesystem / process / binary policy yes partial (Landlock) partial (Landlock) no
SSH / exec / upload / sync yes yes yes no (structural)
Port forwarding yes yes yes no today (recoverable for 0.0.0.0 binds)
Workload stdout/stderr in openshell logs in openshell logs in openshell logs only the agent container log (kubectl logs <agent-pod>), not openshell logs
Process attribution on net events full (binary + PID) full (binary + PID) full (binary + PID) none (renders as -(0))
Added caps in sandbox pod yes no no no
Privileged init container no yes no no
Node-level privileged DaemonSet no no yes no
Pods per sandbox 1 1 1 2
Workload/supervisor kernel isolation under Kata no — one pod, one VM/kernel no — one pod, one VM/kernel no — one pod, one VM/kernel yes — separate pods, separate Kata VMs/kernels
Requires NetworkPolicy enforcement no no no yes
OpenShift SCC privileged custom custom + privileged CNI built-in nonroot-v2

When to use which. combined stays the default and the only topology with
the full supervisor contract. sidecar/cni-sidecar keep that contract
(including SSH, exec, and filesystem policy) while lowering pod privilege, at the
cost of a per-pod privileged init container or a node-level CNI DaemonSet.
proxy-pod is for clusters that will not admit in-pod privilege at any level and
for workloads that need policy-enforced egress but never an interactive session —
batch jobs and autonomous agents that ship their own long-running entrypoint.

Operators who want the interactive workflow and low pod privilege on
OpenShift should use cni-sidecar (#2606), not proxy-pod. The two are
complementary, not competing.

proxy-pod also raises the isolation ceiling under a VM-based RuntimeClass:
because the workload and supervisor are in separate pods, Kata Containers
places them in separate VMs with separate kernels. A kernel compromise in the
workload VM does not by itself reach the supervisor or its gateway credentials.
In the in-pod topologies the two share one Kata VM, so the boundary between them
is a namespace boundary, not a hypervisor one. This is unique to proxy-pod.

Why SSH/exec are structurally impossible here, not merely unimplemented: every
relay targets something inside the sandbox, the SSH server lives only in the
process supervisor, and sessions need the workload's PID/mount/network
namespaces (ssh.rs calls setns to enter the sandbox netns). A supervisor in
a separate pod holds none of those. Full analysis in the RFC.

RFC

rfc/proxy-pod-topology-DRAFT.md (included in this PR, unnumbered pending a
maintainer-assigned number). It covers:

  • Motivation — clusters that admit no in-pod privilege; OpenShift as the
    driving case.
  • Proposal — per-sandbox resources (Deployment, headless Service, generated
    proxy CA Secret, two NetworkPolicies), the privilege and credential-isolation
    model, and the NetworkPolicy egress/ingress contract.
  • OpenShift enablement — why cluster DNS peers and their port must be
    configurable (OpenShift runs DNS in openshift-dns on container port 5353,
    not kube-system/53), and why the built-in nonroot-v2 SCC suffices.
  • Readiness without a supervisor session and running a workload with no
    supervisor to launch it
    — the two adoption blockers found during cluster
    validation, with the fixes.
  • Why relays cannot cross the pod boundary and Observability — the
    structural feature and telemetry losses, measured on-cluster.
  • Feature availability tables, Risks, Alternatives, and
    Open questions.

The RFC records results measured against a live OpenShift 4.22 / OVN-Kubernetes
cluster, including the SCC split (the agent pod admits under stock
restricted-v2; only the supervisor needs nonroot-v2).

Related Issue

Continues @TaylorMutch's proxy-pod PR #2077, which references #1827, #981,
#899, and #1305. Maintainers assign the RFC number from the originating issue
before it leaves draft.

Changes

Topology (builds on @TaylorMutch's #2077):

  • Add the proxy-pod supervisor topology: per-sandbox supervisor Deployment,
    headless Service, generated proxy CA Secret, and agent-egress /
    supervisor-ingress NetworkPolicy pair. The Deployment, Service, Secret, and
    supervisor-ingress policy are owner-referenced to the Sandbox CR and
    garbage-collected with it. The agent-egress fence is deliberately not
    owner-referenced: Kubernetes GC does not order sibling deletion, so a GC-owned
    fence could be removed alongside the workload pod and let a SIGTERM-ignoring
    workload regain direct egress during its grace period. The gateway instead
    manages the fence directly — deleting it only after the workload pod is gone,
    and reaping any fence orphaned by a gateway crash.
  • Run the sandbox image directly as a non-root workload with all capabilities
    dropped; inject only proxy and CA-trust environment.
  • Companion names are keyed on the immutable sandbox UUID (with the CR name for
    readability), so they stay stable and unique across sandbox-name reuse.
  • Nested proxy_pod.proxy_uid / proxy_pod.affinity configuration and Helm
    values.

OpenShift enablement (new):

  • Configurable cluster DNS peers (proxy_pod.dns_peers, Helm
    supervisor.proxyPod.dnsPeers) with per-peer namespace/pod selectors and
    port, defaulting to the upstream kube-system conventions. An empty list
    is rejected, and an empty peer renders no rule rather than an allow-all rule.
  • Gated nonroot-v2 SCC grant (sandboxServiceAccount.openshift.nonrootSCC,
    default off) — a ClusterRole/Binding only, no custom SCC object.

Readiness and workload command (new):

  • SupervisorSessionModel on the driver DriverSandboxStatus contract
    (UNSPECIFIED preserves existing behavior). The gateway derives readiness
    from backend conditions when a topology reports it runs no in-sandbox
    supervisor.
  • wait-for-proxy agent-pod init container (new wait-for-tcp supervisor
    subcommand) so pod readiness transitively means egress works.
  • Fold the supervisor Deployment's live availability into sandbox readiness: a
    proxy-pod sandbox whose supervisor has no available replica reports
    Provisioning (not a stale Ready) and recovers when the supervisor does. In
    shared mode a supervisor Deployment watch pushes that status within seconds;
    every mode's get/list and the periodic reconcile fold the same check as a
    backstop. Availability is tri-state, so a transient Deployment-GET error never
    fails open to Ready.
  • containers.agent.command / args via the Kubernetes driver_config
    passthrough (no public API change), rejected in combined/sidecar where the
    supervisor is the entrypoint.

Lifecycle & HA hardening (from review):

  • Periodic companion reconciliation (~30s), alongside the pass at watch
    establishment, corrects supervisor replica drift (e.g. a transiently-failed
    stop-time scale-down leaving a stopped sandbox's supervisor running) and reaps
    egress fences orphaned by a gateway crash — re-confirming the Sandbox CR is
    gone immediately before deleting a fence, so a newly created sandbox never
    loses its fresh fence to the reaper.
  • SupervisorSession=NotApplicable is published as a durable status condition so
    every gateway replica (not just the reconciler lease holder) rejects
    relay-backed RPCs — unary exec, interactive exec, and TCP forwarding —
    with an immediate, terminal error instead of a 15s relay timeout.
  • Migration: supervisor.proxyPod.retainCompanionRbac keeps the companion RBAC,
    periodic reconcile, and readiness watch working for existing proxy-pod
    sandboxes after supervisor.topology is switched away from proxy-pod; the
    driver manages them by their persisted creation-time topology.
  • Least privilege: gateway RBAC is gateway-scoped and grants no delete on
    owner-referenced companions. The supervisor Deployment readiness watch (and
    its list/watch on apps/deployments) is granted only through the
    namespaced Role in shared mode; managed/operator modes fold readiness via
    get/list to avoid cluster-wide Deployment enumeration.

Correctness fixes (found during rebase and cluster testing):

  • Reject corporate upstream-proxy credential Secrets in proxy-pod (would land
    in the workload pod).
  • Scale the supervisor Deployment to zero on stop and back on start (an
    earlier revision that keyed names on the CR name silently scaled a nonexistent
    Deployment — default--rdy vs rdy — now fixed by the UUID-keyed naming
    above).
  • CLI: detect a sessionless topology (via the published SupervisorSession
    status condition, with an error-marker backstop) and print a clear
    explanation instead of ssh exited with status 255.

Docs: RFC, docs/kubernetes/topology.mdx, docs/kubernetes/openshift.mdx,
docs/reference/sandbox-compute-drivers.mdx, docs/reference/gateway-config.mdx,
Helm README, and the debug-openshell-cluster / helm-dev-environment skills.

Testing

  • cargo test -p openshell-driver-kubernetes (251) and -p openshell-server --lib (1427).
  • mise run helm:test — 120 pass (proxy-pod DNS-peer, SCC-grant, RBAC, and
    retainCompanionRbac cases added).
  • mise run pre-commit — clean.
  • CI: the capability-scoped proxy_pod e2e suite runs in branch CI
    (kubernetes-proxy-pod-e2e, on a kind cluster) covering the control-plane
    contract (companion creation, readiness, sessionless relay rejection). The
    CNI-enforced egress-isolation assertions remain a tracked follow-up, since
    kind's default CNI does not enforce NetworkPolicy.
  • Live OpenShift / OVN-Kubernetes (policy-enforcing): sandbox reaches
    Ready; unproxied egress denied and proxied egress policy-evaluated
    (allow → 200, deny → 403 at CONNECT) with the generated CA trusted; DNS
    resolves via openshift-dns:5353; stop/start scale the supervisor
    down/up; the stock base image runs via containers.agent.command; both pods
    admit (agent under restricted-v2, supervisor under nonroot-v2). Review
    hardening re-validated on-cluster: scaling the supervisor to zero flips the
    sandbox to Provisioning within ~1s and it recovers to Ready; the periodic
    reconcile restores a stopped sandbox's errantly-running supervisor and a
    running sandbox's dropped replica; a genuinely orphaned egress fence is reaped
    while a live sandbox's fence is retained; unary and interactive (--tty)
    exec are both rejected immediately with the topology error; the
    owner-referenced companions are GC'd on delete and the gateway-managed egress
    fence is torn down after the workload pod exits.

Checklist

@copy-pr-bot

copy-pr-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

TaylorMutch and others added 26 commits August 24, 2026 08:13
Add the Kubernetes proxy-pod topology with one supervisor Deployment and Service per sandbox, NetworkPolicy confinement, proxy-pod Helm/Skaffold configuration, topology documentation, and focused supervisor identity tests.

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
Signed-off-by: Russell Bryant <rbryant@redhat.com>
The proxy-pod agent egress NetworkPolicy hardcoded its DNS peers as
kube-system/k8s-app=kube-dns and kube-system/k8s-app=coredns. That is an
upstream Kubernetes convention, not a guarantee.

On OpenShift, cluster DNS runs in the openshift-dns namespace with pods
labeled dns.operator.openshift.io/daemonset-dns=default, and kube-system holds
no DNS pods at all. The hardcoded selector matches nothing, so DNS egress falls
through to the policy's implicit deny and the agent pod cannot resolve any
name, including its own paired supervisor Service. The sandbox is inert.

Add proxy_pod.dns_peers (Helm: supervisor.proxyPod.dnsPeers), a list of
namespace/pod label selector pairs, defaulting to the previous upstream
behavior so existing deployments are unaffected.

Reject an empty peer list at startup, and render no DNS rule at all rather
than an empty 'to' array when the list is empty: in NetworkPolicy semantics an
empty 'to' matches every destination, so emitting one would silently open
DNS-port egress cluster-wide.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
In proxy-pod topology the network supervisor runs in its own Deployment, so
it does not stop when the agent pod does. A stopped sandbox kept its
supervisor pod running indefinitely, consuming a pod slot, CPU, and memory
for a sandbox the user believes is stopped.

Scale the paired Deployment to zero on stop and back to one on start. The
scale-down runs only after the workload has actually stopped so a graceful
shutdown that needs egress still has it, and scaling failures are logged
rather than failing the start/stop RPC.

Extract the stop wait loop into wait_for_sandbox_stopped so the scale-down
has a single place to hook, and grant the sandbox Role 'patch' on deployments.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The Kubernetes driver assigns explicit non-root UIDs to sandbox and supervisor
containers. OpenShift's restricted-v2 SCC uses runAsUser: MustRunAsRange and
admits only UIDs inside the namespace's openshift.io/sa.scc.uid-range
annotation, so it rejects both pods.

The built-in nonroot-v2 SCC resolves this without a custom SCC: it is
restricted-v2 with runAsUser: MustRunAsNonRoot and fsGroup: RunAsAny, while
keeping requiredDropCapabilities ALL, allowPrivilegeEscalation false, no
privileged containers, no host namespaces, and seccomp runtime/default. Its
volume allowlist already covers every volume type proxy-pod topology uses.

Add sandboxServiceAccount.openshift.nonrootSCC, default false so non-OpenShift
installs never reference OpenShift-only APIs. When enabled it renders only a
ClusterRole and ClusterRoleBinding granting 'use' on the existing nonroot-v2
SCC; no SecurityContextConstraints object is created.

This makes proxy-pod the first OpenShell topology that runs on OpenShift under
an unmodified, Red Hat-shipped SCC.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
A NetworkPolicy egress rule whose peer is a podSelector is evaluated against
the destination pod after Service address translation, so the rule must carry
the DNS pods' container port, not the Service port.

Upstream CoreDNS listens on 53, so the two coincide. OpenShift's dns-default
listens on 5353 and its Service maps 53 onto it, so a rule allowing port 53
never matches and the agent pod still cannot resolve anything. Verified on
OpenShift 4.22 / OVN-Kubernetes: with the correct selectors but port 53, DNS
failed both via the Service ClusterIP and via the DNS pod IP directly; with
port 5353 it resolves.

Add a per-peer 'port' field defaulting to 53, and emit one egress rule per
peer rather than one shared rule, since a rule's port list applies to all of
its 'to' entries and peers may differ.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Update the RFC with what a live OpenShift 4.22 / OVN-Kubernetes deployment
showed: the DNS peer port mismatch, the measured SCC split between the two
pods, and two usability gaps that block adoption -- the user-supplied workload
command is silently discarded, and sandboxes never leave Provisioning because
nothing opens the supervisor session the Ready transition depends on.

Replace the now-answered open question about OVN-Kubernetes service address
translation with the questions those findings raise.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…ession

The gateway forced SandboxPhase::Provisioning unless a ConnectSupervisor
session was live. That session is opened only by openshell-supervisor-process
and carries only relays -- SSH, exec, port forwarding, file transfer -- so
proxy-pod topology, which has no in-sandbox process supervisor, could never
reach Ready. Verified on OpenShift: both pods running and policy-enforced
egress working end to end, while the sandbox reported Provisioning forever and
every Ready-gated RPC, including stop and start, was unreachable.

Add SupervisorSessionModel to DriverSandboxStatus. UNSPECIFIED preserves the
existing contract, so drivers that never set it are unaffected. The Kubernetes
driver reports NONE for proxy-pod and REQUIRED otherwise, and the gateway then
derives readiness from the backend conditions alone.

Ready must not become a lie in the process. The agent pod gains a
wait-for-proxy init container that blocks on its paired supervisor's proxy
port, so the pod is not Ready until egress actually works. This also closes a
pre-existing ordering gap where the workload could start before the proxy
existed and its early egress simply failed.

Relay-backed RPCs now fail immediately with an explanation naming the topology
instead of waiting out a session timeout that cannot succeed.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
proxy-pod runs the sandbox image directly, with no supervisor to launch a
workload, so the container needs an entrypoint that stays running. OpenShell's
own sandbox images use an interactive shell entrypoint, which reads EOF under
kubelet and exits 0, leaving the pod in CrashLoopBackOff with empty logs.

Add containers.agent.command and containers.agent.args to the Kubernetes
driver_config passthrough, alongside the existing resources and volume_mounts.
This needs no public API change: the initial command supplied to
'sandbox create' is delivered over the supervisor session, which this topology
does not have.

Reject the fields in combined and sidecar topology, where the driver replaces
the container command with the supervisor binary and an override would be
accepted and then silently dropped.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Per-sandbox proxy-pod resources are named from the sandbox name, but the
stop, start, and delete paths passed the Sandbox CR name. The two differ: a CR
is named <workspace>--<sandbox>, so a sandbox named 'rdy' has CR 'default--rdy'
and Deployment 'os-sup-rdy-<hash>'.

The scale-down on stop therefore patched a Deployment that does not exist and
silently did nothing, leaving the supervisor running for a stopped sandbox --
the exact problem the scaling was added to fix. Delete was affected too, but
owner-reference garbage collection reclaimed the resources anyway and hid it.

Read the sandbox name from the CR's sandbox-name label at both sites, and fall
back to owner-reference GC with a warning if the label is missing. Caught by
cluster testing; the unit tests passed throughout because they never exercised
the CR-name-to-resource-name path.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The earlier draft framed these as two independent gaps and said the driver
silently discarded the workload command. That was wrong about the mechanism:
the initial command from 'sandbox create' is delivered over the supervisor
session after Ready, so it never ran because Ready never arrived.

Rewrite both sections around what the code actually does -- Ready gated on a
relay-carrying session that this topology cannot open -- and record the fixes
and their cluster verification, including the CR-name versus sandbox-name bug
that only on-cluster testing exposed.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The feature list said SSH, exec, and file transfer were unavailable without
saying why, which read as an implementation gap rather than a structural one.

Record the mechanism: RelayOpen targets something 'inside the sandbox', the SSH
server exists only in openshell-supervisor-process, and sessions need the
workload's PID, mount, and network namespaces -- ssh.rs calls setns to enter
the sandbox netns. The sidecar bridge to an abstract socket works only because
both processes share a pod. Note that TCP relays are the exception and are
recoverable for services bound to 0.0.0.0.

Add the observability picture, measured on OpenShift. Network OCSF events,
policy config events, and denial analysis all reach 'openshell logs' as usual,
because log push is gated on sandbox ID and endpoint rather than topology. What
is lost is workload stdout, which now reaches only the container log, and
actor attribution on network events, which renders as -(0) because reading
/proc across a pod boundary is impossible.

Restructure the compatibility tables by concern and record the enforcement
mechanism, pods per sandbox, and OpenShift SCC per topology.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…rror

Creating a sandbox in a topology with no in-sandbox supervisor succeeded, then
failed at the interactive-session step with a bare gRPC error. The sandbox was
running and its network policy enforced, but the output read as a failed
create.

Detect the gateway's rejection and print what actually happened: the sandbox is
running, sessions are unavailable for this topology, egress is unaffected, and
which topologies to use when interactive access is required. When a command was
passed to 'sandbox create', say plainly that it did not run and point at the
containers.agent.command entrypoint override instead.

The command still exits non-zero. A command that did not run must not report
success, and callers should not have to parse output to find that out.

Detection keys on a stable marker constant shared through openshell-core rather
than on prose, so rewording the message cannot silently break it, and it
searches the whole error chain because the marker arrives wrapped in a
transport error.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The previous commit explained the topology after a relay RPC was rejected, but
'sandbox create' still spawned ssh first, so the failure surfaced through the
subprocess as 'ssh exited with status 255' and the explanatory message was
buried in wrapped stderr.

Publish a SupervisorSession=False/NotApplicable condition in the public sandbox
status when the driver reports SupervisorSessionModel::None, and have the CLI
check it before attempting a session. When set, the CLI skips the connect/exec
path entirely and prints the explanation directly: the sandbox is running,
sessions are unavailable for this topology, egress is unaffected, and how to
set a workload entrypoint when a command was supplied.

The error-marker detection from the prior commit stays as the backstop for
relay RPCs issued directly against an existing sandbox, where there is no
create-time status to pre-check.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Add the workload-to-supervisor kernel-isolation property to the topology
comparison: because proxy-pod places the workload and supervisor in separate
pods, a VM-based RuntimeClass like Kata gives them separate VMs and kernels, so
a workload kernel compromise does not by itself reach the supervisor's gateway
credentials. This is unique to proxy-pod; the in-pod topologies share one Kata
VM between workload and supervisor.

Clarify that lost workload stdout/stderr is specifically the agent container's
log, reachable only via 'kubectl logs <agent-pod>', not 'openshell logs'.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Main renamed the canonical-command transport from OPENSHELL_SANDBOX_COMMAND to
the versioned OPENSHELL_MAIN_PROCESS_SPEC (NVIDIA#2726), which the supervisor decodes
and launches. proxy-pod runs the sandbox image directly with no supervisor, so
that env var is not only useless in the workload container but leaks the
intended command into it. Strip MAIN_PROCESS_SPEC alongside the other
supervisor-oriented variables, replacing the now-removed SANDBOX_COMMAND entry.

Rebase adaptation: proxy-pod's workload command continues to flow through the
containers.agent.command driver_config, since the canonical main process
requires an in-sandbox supervisor this topology does not run.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…ps the command

A non-interactive persistent create takes main's implicit-detach path and
returns before the sessionless check ran, so 'openshell sandbox create -- cmd'
against a proxy-pod sandbox silently created a sandbox where the command never
runs -- exactly the broken-looking outcome the sessionless messaging exists to
prevent.

Check for a command-bearing sessionless topology before the detach return and
explain that the command will not run, pointing at containers.agent.command.
The interactive no-command case still reports after detach. Both paths share a
new abort_sessionless_create helper so ephemeral cleanup and messaging stay
identical.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…d placement

Addresses several proxy-pod review findings in the Kubernetes driver:

- Delete the Sandbox CR (tearing down the workload) before removing the
  companion resources, so the agent egress NetworkPolicy fence is never dropped
  while the workload can still egress, and a failed CR delete leaves the fence
  in place.

- Derive companion resource names from the Sandbox CR name, which is unique in
  every workspace mode, instead of the bare sandbox name. In shared mode
  workspace-a/dev and workspace-b/dev share a sandbox name, so the previous
  scheme collided and a rollback could dismantle another sandbox's isolation.

- Create companions in, and point the workload's proxy URL at, the sandbox's
  resolved target namespace rather than the static configured namespace, so
  proxy-pod works in managed and operator workspace modes. Add the proxy-pod
  resources to the cluster-scoped Role for those modes.

- Remove the raw gateway-forward tunnel (supervisor :18080 to the gateway,
  reachable by the agent). Nothing on the agent consumed it, and with
  unauthenticated gateway access it was a policy-bypassing path to the admin
  API. The supervisor still connects to the gateway directly for its own
  policy, inference, and log traffic.

- Return an error from supervisor Deployment scaling and propagate it from
  start_sandbox, so a transient scale-up failure surfaces instead of wedging
  the sandbox in Starting with the supervisor at zero replicas. Scale-down on
  stop stays best-effort.

- Give the supervisor Deployment the workload's nodeSelector, tolerations, and
  priorityClassName, so required same-node affinity cannot pin the workload to
  a node its own placement excludes.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
set_sessionless recorded proxy-pod sandboxes in the supervisor session
registry, but forget_sessionless was never called, so the set grew without
bound as ephemeral proxy-pod sandboxes were created and deleted.

Clear the marker in cleanup_sandbox_state, which runs on permanent removal. It
is deliberately not cleared in the stopped-session cleanup: the sessionless
property is a topology fact that must survive stop/start.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…teps

The sessionless check ran after the structured-output early return and after
the upload, forward, and editor steps. So sandbox create --output json -- <cmd>
exited zero while proxy-pod silently discarded the command, and upload,
forward, or editor failures on --no-keep bypassed cleanup and leaked the
ephemeral sandbox.

Detect the sessionless topology at the top of the Ready arm. When a
session-requiring operation was requested (a command, upload, forward, or
editor), abort immediately -- cleaning up an ephemeral sandbox and reporting a
non-zero exit -- before any of those steps or a structured-success print. A
bare create with no such operation still succeeds (the network-only sandbox is
created), emitting structured output when requested and detaching otherwise.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The e2e:kubernetes:proxy-pod task ran the generic Kubernetes suite, whose smoke
test execs a command and reads its output -- capabilities proxy-pod lacks, so
the suite could not pass.

Add tests/proxy_pod.rs (feature e2e-kubernetes-proxy-pod) covering the
topology's actual contract: a workload whose entrypoint is set through
containers.agent.command reaches Ready, and relay-backed operations (exec) are
rejected with a topology-specific error. Scope the task to this suite instead
of the incompatible generic one.

The NetworkPolicy egress boundary is asserted at the unit level in the driver
and validated manually on a policy-enforcing cluster; a self-probing egress e2e
needs a workload image that tests its own egress and reports through
'openshell logs', tracked as follow-up.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Update the topology diagram, NetworkPolicy contract, and validation table now
that the supervisor no longer forwards a raw gateway tunnel, and note in the
credential-isolation section that the workload has no network path to the
gateway at all.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
… template

The companion resources are named from the Sandbox CR name, but the workload
pod template still derived the CA secret mount and HTTP_PROXY Service name from
the bare sandbox name, and the create-rollback path cleaned up bare-name
resources in the static namespace. In shared mode the workload then mounted a
CA secret that did not exist and never became Ready.

Thread the CR name through SandboxPodParams and use it for the pod template's
companion references and the rollback cleanup, matching create_proxy_pod_resources.
Caught by re-testing a shared-mode create on the cluster.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Address code review feedback on the proxy-pod topology:

- Clean up companions only on a confirmed CR delete, never on a 409
  (replacement) or 404, so a concurrent replacement keeps its Deployment,
  Secret, Service, and egress NetworkPolicy.
- On failed companion creation, delete the CR by its returned name with a
  UID precondition before removing companions, so shared mode no longer
  targets the wrong CR and leaves the workload unfenced.
- Persist the creation-time supervisor topology on each Sandbox CR and
  derive status and start/stop/delete behavior from it, so a later gateway
  topology change does not reinterpret existing sandboxes.
- Mirror the workload's public platform_config placement (runtime class,
  node selector, tolerations) onto the supervisor Deployment so same-node
  affinity stays schedulable and runtimes match.
- Grant proxy-pod ClusterRole RBAC in operator mode, not just managed.
- Restrict the OpenShift nonroot-v2 SCC option to shared workspace mode
  and document the constraint; fail the Helm render otherwise.
- Correct the debug skill to reference only supervisor ingress port 3128
  and note workspace-mode RBAC scoping.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
@russellb
russellb force-pushed the feat/kubernetes-proxy-pod-topology branch from 3f1f2f2 to 492ff77 Compare August 24, 2026 13:00
Address round-two review feedback:

- Scope the proxy-pod ClusterRole to least privilege: grant Secrets (and
  Services and NetworkPolicies) create/delete only, matching the namespaced
  Role. The gateway never reads Secrets, so it no longer holds cluster-wide
  Secret read access.
- Stop eagerly deleting companion resources on sandbox delete and on
  create-failure rollback. Every companion is owner-referenced to the Sandbox
  CR, so garbage collection removes them as part of the CR teardown; deleting
  the egress NetworkPolicy eagerly raced the workload pod's termination grace
  period and could reopen unrestricted egress.
- Resolve the supervisor Service with a search-domain-relative name instead of
  a hardcoded .svc.cluster.local, so clusters with a custom cluster domain can
  resolve it.
- Note in the openshell-cli skill that proxy-pod topology is sessionless and
  rejects trailing commands, uploads, connect, and exec.
- Remove a duplicated proxy_pod.proxy_uid row from the compute-drivers doc.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
A gateway crash between the Sandbox CR create and its companion creates left
a persisted CR with a partial topology that ordinary reconciliation never
repaired, because the CR already existed.

- Apply companions with create-if-absent semantics (AlreadyExists treated as
  success), so provisioning is idempotent and additive: an existing CA Secret
  keeps its key material and an existing supervisor Deployment keeps its
  replica count.
- Reconcile companions for every existing proxy-pod Sandbox CR on each
  watch_sandboxes call (gateway start and watch re-establishment), rebuilding
  the render inputs — placement and log level — from the CR's own agent pod so
  a repaired supervisor lands where the workload can pair with it.
- Share the SandboxPodParams builder between the create and reconcile paths so
  both render identical companions.

Validated on OpenShift/OVN-Kubernetes: deleting a supervisor Deployment and
restarting the gateway recreates it (checked=1 failed=0), and deleting a
sandbox garbage-collects all companions via owner references.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…ned topology

In combined topology the network supervisor shares the workload's container and
inherits the workload image's baked-in environment. Honoring
OPENSHELL_PROXY_BIND_ADDR there let an untrusted image publish the
credential-bearing policy proxy on the pod network (e.g. 0.0.0.0:3128), turning
the sandbox into a confused deputy; OPENSHELL_PROXY_CA_CERT_PATH/KEY_PATH
similarly let an image substitute an attacker-controlled CA.

Gate both on a trusted launch context: only a standalone network supervisor
(proxy-pod/sidecar; process supervision not co-located) may take its bind
address and CA files from the environment, which the driver sets in a separate
container built from the trusted supervisor image. A combined supervisor now
binds to the namespace-scoped veth IP and always generates an ephemeral CA,
ignoring image-supplied values.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…rd upstream proxy, track supervisor readiness

Address round-three review warnings:

- Key companion resource names on the immutable sandbox UUID with a 64-bit
  suffix instead of a 32-bit hash of the truncatable CR name, which had a
  deterministic collision path. Distinct sandbox instances now never share a
  companion name, closing the stale-object reuse window.
- On an AlreadyExists (409) conflict, fetch the object and confirm it is owned
  by the same Sandbox CR before treating the create as idempotent; fail closed
  when a different instance owns it, so a new sandbox never adopts a stale
  companion.
- Forward the operator's corporate upstream proxy from the proxy-pod supervisor
  (URL, no_proxy, CONNECT mode), matching sidecar topology, so egress is not
  silently dropped or routed around the required monitoring path. Credentials
  are excluded because the supervisor pod does not mount the auth Secret.
- Fold supervisor Deployment availability into sandbox status: a proxy-pod
  sandbox whose supervisor has no available replica reports NotReady
  (SupervisorUnavailable) rather than staying Ready with a dead egress path.

Also updates the debug skill and the proxy-pod RFC risks to reflect these
mitigations.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…conciliation

Address round-four review (RBAC and reconciliation correctness):

- Grant no `delete` on proxy-pod companion Deployments, Services, Secrets, or
  NetworkPolicies. The gateway never deletes companions — garbage collection
  removes them with the Sandbox CR — so these verbs were unused and let a
  compromised gateway delete arbitrary cluster resources (Critical 1).
- Grant `get` on the non-secret companions (Services, NetworkPolicies; already
  present for Deployments) so crash-recovery reconciliation can verify an
  existing companion's owner before adopting it. Previously reconciliation's
  409-verification GET hit a 403 and stopped, so partial companion sets were
  never repaired (Warning 1).
- Keep Secrets at `create` only: the gateway never reads Secret contents. The CA
  Secret skips ownership verification on 409 (its UUID-keyed name already
  implies it is this sandbox's own), and ownership verification treats a 403 as
  "accept" rather than wedging reconciliation.
- Scope companion reconciliation to the gateway's own sandboxes and drive it
  from each CR's persisted creation-time topology instead of the gateway's
  current config, so one gateway never repairs another's resources and a gateway
  reconfigured to `combined` still reconciles pre-existing proxy-pod sandboxes
  (Warning 2).
- Correct the compute-drivers reference: proxy-pod runs no in-pod supervisor,
  is sessionless, and its workload logs are not in `openshell logs`.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
… exit

Owner-reference garbage collection does not order sibling deletion, so a fence
owned by the Sandbox CR was removed concurrently with the workload pod; a pod
that ignores SIGTERM could regain direct egress during its termination grace
period (CWE-693).

Make the agent egress NetworkPolicy gateway-managed instead of GC-owned:

- Create it with no ownerReference so garbage collection never removes it.
- On delete, delete the Sandbox CR, wait for the workload pod to disappear, then
  delete the fence explicitly. If the pod cannot be confirmed gone, leave the
  fence for reconciliation rather than dropping it on a guess.
- Reap orphaned fences in reconciliation (an os-eg-* policy whose Sandbox CR no
  longer exists), covering a gateway crash between CR deletion and fence
  teardown. On create-failure rollback, delete the ownerless fence explicitly.
- Grant delete/list on networkpolicies for this gateway-managed lifecycle; the
  other companions remain GC-owned with no delete grant.

Updates the proxy-pod RFC and debug skill to describe the split teardown.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…en reconciliation

Address round-five review of the proxy-pod egress fence and reconciliation:

- Never drop the egress fence until the workload pod is confirmed gone. The
  create-failure rollback and the reconciliation orphan reaper both now check
  for the agent pod (by immutable sandbox-id + agent-role labels) and retain the
  fence when its absence cannot be confirmed, closing the window where a
  SIGTERM-ignoring workload regained direct egress (findings 1, 2).
- Validate an existing ownerless egress fence on AlreadyExists instead of
  accepting it blindly: fetch it and require its spec and sandbox-id to match the
  intended fence, failing closed on a stale or altered policy (finding 4).
- Never classify an un-annotated (pre-branch) CR as proxy-pod. Such CRs predate
  the topology annotation that every proxy-pod sandbox carries, so the fallback
  collapses to combined rather than the gateway's current topology, avoiding
  misclassifying existing combined/sidecar sandboxes on upgrade (finding 3).
- Reconcile the supervisor Deployment replica count from the CR's operating
  state (Running -> 1, Suspended -> 0) so a crash between the operating-state
  patch and the scale is repaired, rather than recreating a missing Deployment
  with one replica unconditionally (finding 6).
- Fold supervisor Deployment availability into the watch paths too, so a CR
  event never republishes a sandbox as Ready while its supervisor is down
  (finding 7).

Grants pods `list` (proxy-pod only) for the label-based pod-absence checks.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The exec and port-forwarding handlers remapped every relay error to Unavailable,
which the CLI treats as transient and retries. A sessionless (proxy-pod) sandbox
returns FailedPrecondition, a terminal condition that can never succeed, so
standalone port forwarding stayed open retrying forever. Preserve the original
status code (the CLI already treats FailedPrecondition as fatal).

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…contract

The Helm unit tests still asserted delete permissions and a combined
Service/Secret rule that the templates intentionally dropped, so `mise run
helm:test` was red. Update the assertions to the least-privilege contract
(no companion delete, split Service/Secret, Secret create-only, NetworkPolicy
create/delete/get/list for the gateway-managed fence) and correct the obsolete
permission contract in the gateway architecture doc.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…n, and scoped pod checks

Address round-six review of the proxy-pod fence and RBAC:

- Tear the egress fence down on create-failure rollback only after the Sandbox
  CR deletion is confirmed (success or 404) AND the workload pod is gone. A CR
  delete that never reached Kubernetes previously let teardown remove the fence
  while the surviving CR could still create an unfenced workload (finding 1).
- Never treat a vanished fence as provisioned: when create returns 409 but the
  verifying GET returns 404, re-create it rather than returning success, so the
  workload is never left at default-allow (finding 2).
- Add supervisor.proxyPod.retainCompanionRbac to keep companion/fence RBAC while
  migrating a gateway away from proxy-pod with proxy-pod sandboxes still present,
  instead of stripping the permissions their lifecycle needs (finding 3).
- Confirm workload-pod absence with a name-scoped get instead of a cluster-wide
  pods list: the fence records the guarded pod's name (== its CR name) so
  delete/reap address it exactly. Drops the pods:list grant that RBAC could not
  constrain to a selector (finding 4).

Signed-off-by: Russell Bryant <rbryant@redhat.com>
… status

Under HA, only the reconciler lease holder populated the in-memory sessionless
set, so relay-backed RPCs (exec, interactive exec, port forwarding) routed to
another gateway replica waited out the 15s session timeout and returned a
transient Unavailable that the CLI retries — leaving port forwards open against
a sandbox that can never serve them. The exec/interactive/forward handlers now
reject with FailedPrecondition from the durable SupervisorSession=NotApplicable
condition on the stored status, which every replica can read.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
- Show the required openshift-dns:5353 DNS peer and nonroot-v2 SCC grant in the
  proxy-pod topology example, plus the retainCompanionRbac migration note.
- Point the debug skill at the separate supervisor Deployment for proxy-pod
  supervisor logs; the sandbox pod has only the workload agent container.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…ch supervisor Deployments

Companion reconciliation previously ran only when the sandbox watch was
established, so a stop-time supervisor scale-down that failed transiently
(or an egress fence orphaned by a gateway crash) persisted until the watch
re-established. Add a periodic reconcile bound to the watch's lifetime that
re-runs companion reconciliation every 30s, correcting supervisor replica
drift and reaping orphaned fences without waiting for the watch to drop.

Supervisor Deployment availability was also not observed: readiness only
refreshed on get/list queries and the reconcile sweep, so a supervisor
that went unavailable after startup could leave the sandbox reporting Ready
for up to a full sweep. Watch supervisor Deployments (gateway- and
role-scoped) and push a refreshed sandbox status within seconds of an
availability change; direct queries and the periodic reconcile remain as a
backstop.

Grant list and watch on apps/deployments in the proxy-pod Role and
ClusterRole to back the Deployment watch, and update the design and
operator docs accordingly.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Wire the capability-scoped proxy_pod suite into branch E2E as
kubernetes-proxy-pod-e2e and gate the core-e2e-result job on it. CI's kind
cluster uses a non-enforcing CNI, so this exercises the proxy-pod
control-plane contract (companion creation, readiness, sessionless relay
rejection); the CNI-enforced egress isolation test still needs a
policy-enforcing CNI and remains tracked as follow-up.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
The helper is declared in a private module, so pub(crate) is redundant and
trips clippy::redundant_pub_crate under -D warnings. Match the neighboring
SUPERVISOR_SESSION_CONDITION visibility.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…panionRbac

Regenerate the chart README so helm-docs check passes; the
retainCompanionRbac value row was missing.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
… scoping

Close a fence-reaping race, stop supervisor readiness failing open, keep
management alive during a retainCompanionRbac migration, and drop cluster-wide
Deployment enumeration.

- Reap race: the periodic reconcile snapshots live CR ids before listing egress
  policies, so a sandbox created in that window (CR then fence, in that order)
  looked orphaned and its fresh fence could be deleted before its workload pod
  existed, leaving the workload with default-allow egress. Re-confirm the
  Sandbox CR is absent immediately before deleting; retain on "exists" or
  "unknown".

- Fail-open readiness: supervisor availability is now tri-state
  (Available/Unavailable/Unknown). The Deployment watch derives availability
  from the event object itself instead of re-fetching (a GET that could time out
  and republish a dead-egress sandbox as Ready); a definite Unavailable is
  required to downgrade readiness, and Unknown leaves it unchanged.

- Migration: periodic reconciliation and (shared mode) the supervisor
  Deployment watch now run whenever the gateway manages proxy-pod sandboxes -
  either its configured topology is proxy-pod, or a retainCompanionRbac
  migration left proxy-pod sandboxes it still owns - determined from the
  startup reconcile. The Deployment watch degrades to reconcile-only on error
  instead of tearing down the sandbox watch.

- Least privilege: the supervisor Deployment watch runs only in shared
  (single-namespace) mode via the namespaced Role. Managed/operator modes fold
  readiness in through get/list and the periodic reconcile, so list/watch on
  apps/deployments is removed from the cluster-wide ClusterRole.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…cable reason

Interactive (explicit-TTY) exec opened a relay without the durable sessionless
check, so on a follower replica it waited out the 15s relay-open timeout and
returned retryable Unavailable instead of the terminal FailedPrecondition the
unary exec and TCP forwarding paths already return. Reject it up front like the
others.

Also require reason=NotApplicable (not merely status=False) when reading the
durable SupervisorSession condition, so a future driver that reports
SupervisorSession=False for a transient disconnect is not given a terminal relay
rejection. The producer already sets that reason; a shared constant now ties the
two together.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…ance

- openshift.mdx: privileged SCC is the default (combined) topology's
  requirement; sidecar and proxy-pod run under the built-in nonroot-v2 SCC.
- gateway-config.mdx: document proxy_pod.dns_peers with the OpenShift example.
- debug-openshell-cluster skill, gateway.md, and the RFC: reflect that the
  supervisor Deployment watch (and its list/watch on apps/deployments) is
  shared-mode-only, and that retainCompanionRbac keeps proxy-pod sandboxes
  managed through a topology migration.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
@russellb
russellb marked this pull request as ready for review August 25, 2026 16:24
@russellb

Copy link
Copy Markdown
Contributor Author

I'm still running local review, but I think this is ready to look at and try out

…eadiness closed

Two correctness fixes in the proxy-pod driver:

- Fence teardown treated an accepted Sandbox CR DELETE as proof the CR was gone.
  Kubernetes may only have set deletionTimestamp; a finalizer or in-flight
  controller reconciliation can still recreate the workload after the momentary
  pod-absence check, and the fence would already be removed — default-allow
  egress. Before deleting the fence, re-confirm the UID-addressed CR is actually
  absent (or replaced by a different-UID successor); retain the fence otherwise
  so reconciliation reaps it once the CR is truly gone.

- Unknown supervisor availability failed open. A failed Deployment GET left the
  CR's own Ready=True intact, so a watch event or periodic list could overwrite a
  prior DependenciesNotReady with Ready even though the separate supervisor may
  be down. Fail closed: keep Ready only when the supervisor is confirmed
  Available; both Unavailable and Unknown downgrade to Provisioning.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
…e discovery

Whether periodic companion reconciliation and the shared-mode supervisor
Deployment readiness watch stay scheduled was inferred from a runtime sandbox
list at watch establishment. One transient discovery failure returned "manages
none" and froze that decision for the entire watch session, disabling migration
repair for otherwise-healthy proxy-pod sandboxes after the configured topology
was switched away from proxy-pod.

Decide it from configuration instead: add proxy_pod.retain_companion_management
(rendered by Helm from supervisor.proxyPod.retainCompanionRbac) and schedule
upkeep when the configured topology is proxy-pod OR that flag is set. The
watch-establishment reconcile still runs for its repair side effects; its result
no longer gates upkeep, so a flaky list can never disable it.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Only proxy-pod runs sandbox pods under the built-in nonroot-v2 SCC. The sidecar
and cni-sidecar topologies need a custom SCC — their UID-0 network init
container and default root sidecar require added capabilities — so nonroot-v2
would reject them. The prior text wrongly lumped sidecar in with proxy-pod.

Signed-off-by: Russell Bryant <rbryant@redhat.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants