Skip to content

Commit 3ac7ef0

Browse files
committed
feat(kubernetes): warm pooling for sandboxes created from templates
Signed-off-by: Gordon Sim <gsim@redhat.com>
1 parent d0dbbe4 commit 3ac7ef0

76 files changed

Lines changed: 11921 additions & 3953 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎architecture/compute-runtimes.md‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ delete, reconciliation removes the row; otherwise it can remain `Deleting`.
141141
|---|---|---|---|
142142
| Docker | Local development with Docker available. | Container plus nested sandbox namespace. | Uses host networking so loopback gateway endpoints work from the supervisor. |
143143
| Podman | Rootless or single-machine deployments. | Container plus nested sandbox namespace. | Uses the Podman REST API and CDI GPU devices when available. Delivers the supervisor via OCI image volume by default; falls back to extracting the binary to a host-side cache and bind-mounting it when `userns` is configured (overlay does not support idmapped mounts). |
144-
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, and GPU resources. |
144+
| Kubernetes | Cluster deployment through Helm. | Pod plus nested sandbox namespace. | Uses Kubernetes API objects, service accounts, secrets, PVC-backed workspace storage, GPU resources, and optionally Agent Sandbox v1beta1 warm-pool claims. |
145145
| VM | Experimental microVM isolation. | Per-sandbox libkrun VM. | Managed endpoint-backed driver. The gateway spawns `openshell-driver-vm`, waits for its Unix socket, and then consumes it through the same remote `compute_driver.proto` path used by unmanaged endpoint drivers. The VM driver boots a cached bootstrap `rootfs.ext4`, prepares requested OCI images inside a bootstrap VM with `umoci`, attaches the prepared image disk read-only, and gives each sandbox a writable `overlay.ext4` for merged-root changes and runtime material. The driver persists each accepted launch request beside the overlay and restarts those VMs on driver startup without recreating the overlay. |
146146
| Extension | Out-of-tree drivers operated alongside the gateway. | Whatever boundary the driver implements. | Selected by a non-reserved custom `compute_drivers = ["<name>"]` entry with `[openshell.drivers.<name>].socket_path`, or at launch time by pairing `--drivers <name>` with `--compute-driver-socket=<path>`. Reserved built-in names such as `vm`, `docker`, `podman`, and `kubernetes` cannot be used as unmanaged socket endpoints. The gateway connects to a UDS the operator already provisioned, runs `GetCapabilities`, logs the advertised `driver_name`, and dispatches all sandbox lifecycle calls through `compute_driver.proto`. The driver process and socket lifecycle are operator-owned; the gateway does not spawn, supervise, or remove unmanaged extension drivers. The trust boundary is the socket's filesystem permissions: the operator must ensure only the gateway uid can read/write it. |
147147

@@ -248,14 +248,75 @@ The supervisor itself remains root so it can establish isolation before
248248
starting unprivileged children.
249249

250250
Kubernetes supervisors authenticate to the gateway in two stages. They first
251-
call `RegisterSupervisorPod` with the projected ServiceAccount token; the
251+
call `RegisterSupervisor` with the projected ServiceAccount token; the
252252
gateway validates the pod-bound token and live Agent Sandbox owner state, then
253253
activates already-bound cold pods by streaming back a gateway-minted sandbox
254254
JWT. The supervisor installs that JWT in memory and starts the normal
255255
`ConnectSupervisor` session as the activated sandbox. The gateway does not dial
256256
pod IPs or require an inbound activation port; activation is supervisor
257257
initiated over the existing outbound gRPC connection.
258258

259+
When compatible OpenShell-enabled Agent Sandbox warm pools exist, the
260+
Kubernetes driver can create a `SandboxClaim` instead of a direct `Sandbox`.
261+
The driver watches warm pools and templates into a local cache so create
262+
requests do not list or fetch extension objects on the hot path. This remains
263+
behind the compute-driver boundary: other drivers and the driver-agnostic
264+
gateway lifecycle continue to operate on OpenShell sandbox identity and status.
265+
Warm-pool activation uses the same trust boundary as direct Kubernetes
266+
activation. The direct path validates `pod token -> live pod -> owning Sandbox
267+
CR -> Sandbox sandbox-id metadata`; the warm path validates `pod token -> live
268+
pod -> owning Sandbox CR -> associated SandboxClaim -> SandboxClaim sandbox-id
269+
metadata`. In both paths, sandbox-id metadata is not cryptographic proof by
270+
itself. It is trusted because the gateway and Agent Sandbox controllers own the
271+
relevant Kubernetes objects, and RBAC must prevent sandbox workloads and
272+
untrusted users from creating or mutating trusted `Sandbox`, `SandboxClaim`, pod
273+
metadata, or sandbox service-account identity in the gateway-managed namespace.
274+
Under that model, warm pooling does not require a separate anti-spoofing token
275+
or gateway-side claim mapping beyond the same live Kubernetes ownership and
276+
metadata consistency checks used by the direct path. If an operator grants
277+
untrusted principals write access to those objects, both warm and direct
278+
activation require a broader common proof model.
279+
Claim activation is level-triggered rather than dependent on a single watch
280+
event. The controller periodically relists OpenShell-managed claims and retries
281+
failed watch streams, because a claim can become visible before its selected
282+
`Sandbox`, pod, or outbound supervisor registration. A newly authenticated
283+
warm-supervisor registration clears completed-claim hints and triggers an immediate
284+
relist, so both a restarted process with the same pod UID and a replacement pod
285+
with a new UID can reactivate against an existing claim. It reconciles different
286+
claims concurrently, deduplicates work by claim UID, and caps concurrency so
287+
one late registration or slow Kubernetes lookup cannot block activation for
288+
the namespace or create unbounded work.
289+
The gateway retains activated pod-UID tombstones for one hour to reject
290+
duplicate registration and activation races, then prunes them opportunistically
291+
on registry access so long-running gateways do not accumulate one entry per
292+
historical pod. A new authenticated registration supersedes its pod-UID
293+
tombstone and receives a new local session ID. Activation and failure delivery
294+
are conditional on that session ID, preventing work started for an old process
295+
from consuming or terminating its replacement stream.
296+
Warm claim creation is idempotent by the deterministic claim name. After a
297+
create timeout, transport failure, server error, or conflict, the driver reads
298+
that name back and retries the same claim create when it is not yet visible. It
299+
never switches to direct Sandbox creation after an ambiguous claim write. An
300+
existing claim is accepted only when its OpenShell identity, workspace,
301+
allocation marker, and warm-pool reference match the request. If Kubernetes
302+
still cannot determine whether the claim exists, the driver returns
303+
`Unavailable` and the gateway retains the durable `Provisioning` record. This
304+
prevents another create from assigning a new sandbox ID to the same name while
305+
the original Kubernetes write may still be live; the normal driver watcher can
306+
subsequently reconcile an accepted claim.
307+
Drivers opt into OpenShell `SandboxTemplate` lifecycle notifications through
308+
the compute-driver capability snapshot. The Kubernetes driver uses those
309+
notifications, plus a startup sync of persisted templates, to generate Agent
310+
Sandbox `SandboxTemplate` and `SandboxWarmPool` resources for templates whose
311+
`desired_service_level.startup.ready_within` is strictly below the configured
312+
threshold. The generated pool size follows
313+
`desired_service_level.startup.max_burst` capped by the Kubernetes driver
314+
configuration. Sandbox creation and template reconciliation share one
315+
workspace-to-namespace mapping boundary; the current shared-namespace
316+
implementation maps every workspace to the configured Kubernetes namespace.
317+
The read-side warm-pool cache still runs on every replica so local create
318+
requests do not depend on a separate writer lease.
319+
259320
Kubernetes can run the supervisor in the default combined topology or in a
260321
sidecar topology. Combined mode keeps network and process supervision in the
261322
agent container. Sidecar mode runs network enforcement, the proxy, and gateway

‎architecture/gateway.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,24 @@ identity inspection without client-side token decoding.
210210
Sandbox secrets are gateway-signed JWTs bound to a single sandbox ID. Docker,
211211
Podman, and VM drivers deliver the initial token through supervisor-only
212212
runtime material; Kubernetes supervisors exchange a projected ServiceAccount
213-
token through `RegisterSupervisorPod`. The gateway validates that projected
213+
token through `RegisterSupervisor`. The gateway validates that projected
214214
token with Kubernetes `TokenReview`, requires the configured sandbox service
215215
account, checks the returned pod binding against the live pod UID, and verifies
216216
the pod's controlling `Sandbox` ownerReference against the live Sandbox CR UID
217217
and sandbox-id label before sending an activation message with the gateway JWT.
218+
For warm-pooled Kubernetes sandboxes, the same check is re-anchored through the
219+
claim that adopted the warm pod: the live pod must still be controlled by a
220+
live `Sandbox`, that `Sandbox` must be associated with the live
221+
`SandboxClaim`, and the sandbox-id metadata on that claim must identify the
222+
same OpenShell sandbox record before activation can complete. This is not a
223+
separate warm-only spoofing defense; it is the warm-path equivalent of the
224+
direct path's owner-reference and sandbox-id consistency check. Both paths
225+
depend on the same Kubernetes RBAC boundary: sandbox workloads and untrusted
226+
users must not be able to create or mutate trusted Agent Sandbox objects, pod
227+
metadata, or the configured sandbox ServiceAccount in the gateway-managed
228+
namespace. If that boundary is weakened, a fix must cover both direct
229+
`Sandbox` and warm `SandboxClaim` activation rather than adding a warm-only
230+
proof.
218231
The bootstrap path accepts both `agents.x-k8s.io/v1beta1` ownerReferences from
219232
newer Agent Sandbox controllers and `agents.x-k8s.io/v1alpha1` ownerReferences
220233
from existing deployments. `IssueSandboxToken` remains as a compatibility shim

‎crates/openshell-cli/tests/ensure_providers_integration.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -619,14 +619,14 @@ impl OpenShell for TestOpenShell {
619619
Err(Status::unimplemented("not implemented in test"))
620620
}
621621

622-
type RegisterSupervisorPodStream = tokio_stream::wrappers::ReceiverStream<
623-
Result<openshell_core::proto::PodActivationMessage, Status>,
622+
type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream<
623+
Result<openshell_core::proto::SupervisorActivationMessage, Status>,
624624
>;
625625

626-
async fn register_supervisor_pod(
626+
async fn register_supervisor(
627627
&self,
628-
_request: tonic::Request<openshell_core::proto::RegisterSupervisorPodRequest>,
629-
) -> Result<Response<Self::RegisterSupervisorPodStream>, Status> {
628+
_request: tonic::Request<openshell_core::proto::RegisterSupervisorRequest>,
629+
) -> Result<Response<Self::RegisterSupervisorStream>, Status> {
630630
Err(Status::unimplemented("not implemented in test"))
631631
}
632632

‎crates/openshell-cli/tests/mtls_integration.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,14 @@ impl OpenShell for TestOpenShell {
498498
Err(Status::unimplemented("not implemented in test"))
499499
}
500500

501-
type RegisterSupervisorPodStream = tokio_stream::wrappers::ReceiverStream<
502-
Result<openshell_core::proto::PodActivationMessage, Status>,
501+
type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream<
502+
Result<openshell_core::proto::SupervisorActivationMessage, Status>,
503503
>;
504504

505-
async fn register_supervisor_pod(
505+
async fn register_supervisor(
506506
&self,
507-
_request: tonic::Request<openshell_core::proto::RegisterSupervisorPodRequest>,
508-
) -> Result<Response<Self::RegisterSupervisorPodStream>, Status> {
507+
_request: tonic::Request<openshell_core::proto::RegisterSupervisorRequest>,
508+
) -> Result<Response<Self::RegisterSupervisorStream>, Status> {
509509
Err(Status::unimplemented("not implemented in test"))
510510
}
511511

‎crates/openshell-cli/tests/provider_commands_integration.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,14 +1022,14 @@ impl OpenShell for TestOpenShell {
10221022
Err(Status::unimplemented("not implemented in test"))
10231023
}
10241024

1025-
type RegisterSupervisorPodStream = tokio_stream::wrappers::ReceiverStream<
1026-
Result<openshell_core::proto::PodActivationMessage, Status>,
1025+
type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream<
1026+
Result<openshell_core::proto::SupervisorActivationMessage, Status>,
10271027
>;
10281028

1029-
async fn register_supervisor_pod(
1029+
async fn register_supervisor(
10301030
&self,
1031-
_request: tonic::Request<openshell_core::proto::RegisterSupervisorPodRequest>,
1032-
) -> Result<Response<Self::RegisterSupervisorPodStream>, Status> {
1031+
_request: tonic::Request<openshell_core::proto::RegisterSupervisorRequest>,
1032+
) -> Result<Response<Self::RegisterSupervisorStream>, Status> {
10331033
Err(Status::unimplemented("not implemented in test"))
10341034
}
10351035

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -792,14 +792,14 @@ impl OpenShell for TestOpenShell {
792792
Err(Status::unimplemented("not implemented in test"))
793793
}
794794

795-
type RegisterSupervisorPodStream = tokio_stream::wrappers::ReceiverStream<
796-
Result<openshell_core::proto::PodActivationMessage, Status>,
795+
type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream<
796+
Result<openshell_core::proto::SupervisorActivationMessage, Status>,
797797
>;
798798

799-
async fn register_supervisor_pod(
799+
async fn register_supervisor(
800800
&self,
801-
_request: tonic::Request<openshell_core::proto::RegisterSupervisorPodRequest>,
802-
) -> Result<Response<Self::RegisterSupervisorPodStream>, Status> {
801+
_request: tonic::Request<openshell_core::proto::RegisterSupervisorRequest>,
802+
) -> Result<Response<Self::RegisterSupervisorStream>, Status> {
803803
Err(Status::unimplemented("not implemented in test"))
804804
}
805805

‎crates/openshell-cli/tests/sandbox_name_fallback_integration.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -586,14 +586,14 @@ impl OpenShell for TestOpenShell {
586586
Err(Status::unimplemented("not implemented in test"))
587587
}
588588

589-
type RegisterSupervisorPodStream = tokio_stream::wrappers::ReceiverStream<
590-
Result<openshell_core::proto::PodActivationMessage, Status>,
589+
type RegisterSupervisorStream = tokio_stream::wrappers::ReceiverStream<
590+
Result<openshell_core::proto::SupervisorActivationMessage, Status>,
591591
>;
592592

593-
async fn register_supervisor_pod(
593+
async fn register_supervisor(
594594
&self,
595-
_request: tonic::Request<openshell_core::proto::RegisterSupervisorPodRequest>,
596-
) -> Result<Response<Self::RegisterSupervisorPodStream>, Status> {
595+
_request: tonic::Request<openshell_core::proto::RegisterSupervisorRequest>,
596+
) -> Result<Response<Self::RegisterSupervisorStream>, Status> {
597597
Err(Status::unimplemented("not implemented in test"))
598598
}
599599

‎crates/openshell-core/src/driver_utils.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,11 +380,28 @@ pub fn build_capabilities_response(
380380
driver_name: &str,
381381
driver_version: impl Into<String>,
382382
default_image: impl Into<String>,
383+
) -> GetCapabilitiesResponse {
384+
build_capabilities_response_with_template_lifecycle(
385+
driver_name,
386+
driver_version,
387+
default_image,
388+
false,
389+
)
390+
}
391+
392+
/// Build a [`GetCapabilitiesResponse`] and opt into sandbox-template lifecycle
393+
/// notifications.
394+
pub fn build_capabilities_response_with_template_lifecycle(
395+
driver_name: &str,
396+
driver_version: impl Into<String>,
397+
default_image: impl Into<String>,
398+
supports_sandbox_template_lifecycle: bool,
383399
) -> GetCapabilitiesResponse {
384400
GetCapabilitiesResponse {
385401
driver_name: driver_name.to_string(),
386402
driver_version: driver_version.into(),
387403
default_image: default_image.into(),
404+
supports_sandbox_template_lifecycle,
388405
}
389406
}
390407

‎crates/openshell-core/src/error.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ pub enum ComputeDriverError {
122122
/// A precondition for the operation was not met.
123123
#[error("{0}")]
124124
Precondition(String),
125+
/// The backend may have accepted the operation, but its result could not be
126+
/// determined. Callers must preserve durable intent for reconciliation.
127+
#[error("{0}")]
128+
Unavailable(String),
125129
/// Generic error message.
126130
#[error("{0}")]
127131
Message(String),
@@ -134,6 +138,7 @@ impl From<ComputeDriverError> for tonic::Status {
134138
ComputeDriverError::NotFound => Self::not_found("sandbox not found"),
135139
ComputeDriverError::InvalidArgument(m) => Self::invalid_argument(m),
136140
ComputeDriverError::Precondition(m) => Self::failed_precondition(m),
141+
ComputeDriverError::Unavailable(m) => Self::unavailable(m),
137142
ComputeDriverError::Message(m) => Self::internal(m),
138143
}
139144
}

0 commit comments

Comments
 (0)