fix(kubernetes): recreate split-pod supervisors

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
This commit is contained in:
Taylor Mutch
2026-06-29 13:53:01 -07:00
parent ca50abe0b2
commit efee2f4674
14 changed files with 412 additions and 86 deletions
@@ -286,13 +286,16 @@ should read it for binary-scoped policy decisions; if allowed network rules are
all denied, inspect that file and the network sidecar logs.
If `supervisor_topology = "split-pod"` is rendered, each sandbox should have a
separate supervisor pod, a headless supervisor Service, a proxy CA Secret, and
two per-sandbox NetworkPolicies. The agent pod should have
`openshell.ai/sandbox-role=agent`; the supervisor pod should have
separate supervisor Deployment with one supervisor pod, a headless supervisor
Service, a proxy CA Secret, and two per-sandbox NetworkPolicies. The agent pod
should have `openshell.ai/sandbox-role=agent`; the supervisor pod should have
`openshell.ai/sandbox-role=supervisor`; both should share the same
`openshell.ai/sandbox-id`. The supervisor pod must have a controlling
`Sandbox` ownerReference and the `openshell.io/sandbox-id` annotation so the
TokenReview bootstrap path can mint a sandbox JWT. If the agent cannot reach
`openshell.ai/sandbox-id`. The supervisor Deployment must have a controlling
`Sandbox` ownerReference. The Deployment pod template must carry the
`openshell.io/sandbox-id` annotation so the TokenReview bootstrap path can mint
a sandbox JWT. For supervisor pods, the gateway validates the
`Pod -> ReplicaSet -> Deployment -> Sandbox` owner chain, so missing
`apps/replicasets get` RBAC can also break bootstrap. If the agent cannot reach
the gateway, check DNS to the headless Service, the agent egress NetworkPolicy
DNS exception for kube-dns/CoreDNS, and the supervisor ingress NetworkPolicy
allowing only that agent pod on ports `3128` and `18080`.
+5 -4
View File
@@ -74,10 +74,11 @@ All Skaffold commands build the `gateway` and `supervisor` images and deploy the
chart. The sidecar profile renders an `openshell-network-init` init container for
nftables setup and a non-root `openshell-supervisor-network` runtime sidecar for
proxying. The split-pod profile renders network supervision in a separate
supervisor pod and relies on Kubernetes NetworkPolicy enforcement so the agent
pod can reach only its paired supervisor plus DNS. The default local k3s/k3d
cluster keeps k3s's embedded NetworkPolicy controller enabled; if you replace
the CNI, install a policy-enforcing CNI before using split-pod. The
supervisor Deployment with one pod and relies on Kubernetes NetworkPolicy
enforcement so the agent pod can reach only its paired supervisor plus DNS. The
default local k3s/k3d cluster keeps k3s's embedded NetworkPolicy controller
enabled; if you replace the CNI, install a policy-enforcing CNI before using
split-pod. The
`pkiInitJob` hook (a pre-install Job that runs `openshell-gateway
generate-certs`) generates mTLS secrets on first install. Envoy Gateway opt-in;
see the Optional Add-ons section below.
+5 -3
View File
@@ -64,9 +64,11 @@ Podman, and VM drivers deliver the initial token through supervisor-only
runtime material; Kubernetes supervisors exchange a projected ServiceAccount
token through `IssueSandboxToken`. The gateway validates that projected token
with Kubernetes `TokenReview`, requires the configured sandbox service account,
checks the returned pod binding against the live pod UID, and verifies the pod's
controlling `Sandbox` ownerReference against the live Sandbox CR UID and
sandbox-id label before minting the gateway JWT. The bootstrap path accepts
checks the returned pod binding against the live pod UID, and verifies the
pod's ownership against the live Sandbox CR UID and sandbox-id label before
minting the gateway JWT. Agent pods must be directly controlled by the
`Sandbox` CR. Split-pod supervisor pods may be controlled through the Kubernetes
`Pod -> ReplicaSet -> Deployment -> Sandbox` chain. The bootstrap path accepts
both `agents.x-k8s.io/v1beta1` ownerReferences from newer Agent Sandbox
controllers and `agents.x-k8s.io/v1alpha1` ownerReferences from existing
deployments. Supervisors renew gateway JWTs in memory before expiry only while
+6 -5
View File
@@ -67,11 +67,12 @@ filesystem policy, process privilege dropping, or process/binary identity
checks. Network endpoint and L7 policy remain enforced by the network sidecar.
The `split-pod` supervisor topology runs network enforcement and gateway
forwarding in a separate supervisor pod. The agent pod runs only the
process-mode supervisor and reaches the supervisor through a per-sandbox
headless Service. The driver creates owner-referenced supervisor Pod, Service,
proxy CA Secret, and NetworkPolicy resources so agent egress is limited to its
paired supervisor pod plus DNS.
forwarding in a separate supervisor Deployment with one pod. The agent pod runs
only the process-mode supervisor and reaches the supervisor through a
per-sandbox headless Service. The driver creates an owner-referenced supervisor
Deployment with one replica plus Service, proxy CA Secret, and NetworkPolicy
resources so agent egress is limited to its paired supervisor pod plus DNS. If
the supervisor pod is deleted, the Deployment recreates it.
Sidecar mode uses the pod `fsGroup` to make the projected service-account token
and sandbox client TLS secret group-readable so the non-root process supervisor
@@ -10,7 +10,8 @@ use crate::config::{
SupervisorTopology,
};
use futures::{Stream, StreamExt, TryStreamExt};
use k8s_openapi::api::core::v1::{Event as KubeEventObj, Namespace, Node, Pod, Secret, Service};
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::{Event as KubeEventObj, Namespace, Node, Secret, Service};
use k8s_openapi::api::networking::v1::NetworkPolicy;
use kube::api::{Api, ApiResource, DeleteParams, ListParams, PostParams};
use kube::core::gvk::GroupVersionKind;
@@ -649,7 +650,7 @@ impl KubernetesComputeDriver {
.map(|template| template.environment.clone())
.unwrap_or_default();
let spec_environment = spec_pod_env(spec);
let pod_owner_ref = split_owner_reference(sandbox_cr, sandbox_api_version, true)?;
let deployment_owner_ref = split_owner_reference(sandbox_cr, sandbox_api_version, true)?;
let dependent_owner_ref = split_owner_reference(sandbox_cr, sandbox_api_version, false)?;
let (ca_cert_pem, ca_key_pem) = generate_split_proxy_ca()?;
@@ -665,19 +666,20 @@ impl KubernetesComputeDriver {
split_agent_egress_network_policy(&names, params, dependent_owner_ref.clone());
let supervisor_ingress =
split_supervisor_ingress_network_policy(&names, params, dependent_owner_ref);
let supervisor_pod = split_supervisor_pod(
let supervisor_deployment = split_supervisor_deployment(
&names,
&template_environment,
&spec_environment,
params,
pod_owner_ref,
deployment_owner_ref,
);
let secrets: Api<Secret> = Api::namespaced(self.client.clone(), &self.config.namespace);
let services: Api<Service> = Api::namespaced(self.client.clone(), &self.config.namespace);
let policies: Api<NetworkPolicy> =
Api::namespaced(self.client.clone(), &self.config.namespace);
let pods: Api<Pod> = Api::namespaced(self.client.clone(), &self.config.namespace);
let deployments: Api<Deployment> =
Api::namespaced(self.client.clone(), &self.config.namespace);
tokio::time::timeout(
KUBE_API_TIMEOUT,
@@ -729,12 +731,12 @@ impl KubernetesComputeDriver {
.map_err(KubernetesDriverError::from_kube)?;
tokio::time::timeout(
KUBE_API_TIMEOUT,
pods.create(&PostParams::default(), &supervisor_pod),
deployments.create(&PostParams::default(), &supervisor_deployment),
)
.await
.map_err(|_| {
KubernetesDriverError::Message(format!(
"timed out after {}s creating split-pod supervisor pod",
"timed out after {}s creating split-pod supervisor deployment",
KUBE_API_TIMEOUT.as_secs()
))
})?
@@ -743,7 +745,7 @@ impl KubernetesComputeDriver {
info!(
sandbox_id = %sandbox.id,
sandbox_name = %sandbox.name,
supervisor_pod = %names.supervisor_pod,
supervisor_deployment = %names.supervisor_deployment,
service = %names.service,
"Created split-pod supervisor resources"
);
@@ -756,11 +758,12 @@ impl KubernetesComputeDriver {
let services: Api<Service> = Api::namespaced(self.client.clone(), &self.config.namespace);
let policies: Api<NetworkPolicy> =
Api::namespaced(self.client.clone(), &self.config.namespace);
let pods: Api<Pod> = Api::namespaced(self.client.clone(), &self.config.namespace);
let deployments: Api<Deployment> =
Api::namespaced(self.client.clone(), &self.config.namespace);
let _ = tokio::time::timeout(
KUBE_API_TIMEOUT,
pods.delete(&names.supervisor_pod, &DeleteParams::default()),
deployments.delete(&names.supervisor_deployment, &DeleteParams::default()),
)
.await;
let _ = tokio::time::timeout(
@@ -1460,7 +1463,7 @@ fn gateway_tls_server_name(grpc_endpoint: &str) -> Option<String> {
#[derive(Debug, Clone)]
struct SplitPodResourceNames {
supervisor_pod: String,
supervisor_deployment: String,
service: String,
proxy_ca_secret: String,
agent_egress_network_policy: String,
@@ -1469,7 +1472,7 @@ struct SplitPodResourceNames {
fn split_pod_resource_names(sandbox_name: &str) -> SplitPodResourceNames {
SplitPodResourceNames {
supervisor_pod: dns_label_name("os-sup", sandbox_name),
supervisor_deployment: dns_label_name("os-sup", sandbox_name),
service: dns_label_name("os-svc", sandbox_name),
proxy_ca_secret: dns_label_name("os-ca", sandbox_name),
agent_egress_network_policy: dns_label_name("os-eg", sandbox_name),
@@ -3158,13 +3161,13 @@ fn split_supervisor_service(
}))
}
fn split_supervisor_pod(
fn split_supervisor_deployment(
names: &SplitPodResourceNames,
template_environment: &std::collections::HashMap<String, String>,
spec_environment: &std::collections::HashMap<String, String>,
params: &SandboxPodParams<'_>,
owner_ref: serde_json::Value,
) -> Pod {
) -> Deployment {
let mut container = serde_json::json!({
"name": SUPERVISOR_NETWORK_SIDECAR_NAME,
"image": params.supervisor_image,
@@ -3302,16 +3305,30 @@ fn split_supervisor_pod(
}
k8s_object(serde_json::json!({
"apiVersion": "v1",
"kind": "Pod",
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": split_pod_object_meta(
&names.supervisor_pod,
&names.supervisor_deployment,
params.namespace,
params.sandbox_id,
SANDBOX_ROLE_SUPERVISOR,
owner_ref
),
"spec": spec
"spec": {
"replicas": 1,
"selector": {
"matchLabels": split_match_labels(params.sandbox_id, SANDBOX_ROLE_SUPERVISOR)
},
"template": {
"metadata": {
"labels": split_labels(params.sandbox_id, SANDBOX_ROLE_SUPERVISOR),
"annotations": {
"openshell.io/sandbox-id": params.sandbox_id
}
},
"spec": spec
}
}
}))
}
@@ -4512,7 +4529,7 @@ mod tests {
"blockOwnerDeletion": false
});
let supervisor = serde_json::to_value(split_supervisor_pod(
let supervisor = serde_json::to_value(split_supervisor_deployment(
&names,
&std::collections::HashMap::new(),
&std::collections::HashMap::new(),
@@ -4532,15 +4549,25 @@ mod tests {
supervisor["metadata"]["labels"][LABEL_SANDBOX_ROLE],
SANDBOX_ROLE_SUPERVISOR
);
assert_eq!(supervisor["kind"], "Deployment");
assert_eq!(supervisor["spec"]["replicas"], 1);
assert_eq!(
supervisor["spec"]["hostAliases"][0]["ip"],
supervisor["spec"]["selector"]["matchLabels"][LABEL_SANDBOX_ROLE],
SANDBOX_ROLE_SUPERVISOR
);
assert_eq!(
supervisor["spec"]["template"]["metadata"]["labels"][LABEL_SANDBOX_ROLE],
SANDBOX_ROLE_SUPERVISOR
);
assert_eq!(
supervisor["spec"]["template"]["spec"]["hostAliases"][0]["ip"],
params.host_gateway_ip
);
let hostnames = supervisor["spec"]["hostAliases"][0]["hostnames"]
let hostnames = supervisor["spec"]["template"]["spec"]["hostAliases"][0]["hostnames"]
.as_array()
.unwrap();
assert!(hostnames.contains(&serde_json::json!("host.openshell.internal")));
let container = &supervisor["spec"]["containers"][0];
let container = &supervisor["spec"]["template"]["spec"]["containers"][0];
assert_eq!(
rendered_env(container, openshell_core::sandbox_env::PROXY_BIND_ADDR),
Some("0.0.0.0:3128")
+255 -29
View File
@@ -5,8 +5,9 @@
//!
//! Path-scoped to `IssueSandboxToken`. Validates a projected SA token
//! presented by a sandbox pod, reads the pod's `openshell.io/sandbox-id`
//! annotation, verifies the pod is controlled by the corresponding Sandbox CR,
//! and returns a [`Principal::Sandbox`] with
//! annotation, verifies the pod is controlled by the corresponding Sandbox CR
//! either directly or through a supervisor Deployment controller chain, and
//! returns a [`Principal::Sandbox`] with
//! [`SandboxIdentitySource::K8sServiceAccount`]. The `IssueSandboxToken` handler
//! then mints a gateway-signed JWT for that sandbox id; subsequent gRPC calls
//! from the supervisor use the gateway-minted JWT validated by
@@ -19,10 +20,11 @@ use super::authenticator::Authenticator;
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
use async_trait::async_trait;
use k8s_openapi::api::{
apps::v1::{Deployment, ReplicaSet},
authentication::v1::{TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo},
core::v1::Pod,
};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, OwnerReference};
use kube::Error as KubeError;
use kube::api::{Api, ApiResource, PostParams};
use kube::core::{DynamicObject, gvk::GroupVersionKind};
@@ -45,7 +47,10 @@ const SANDBOX_API_VERSION_V1BETA1: &str = "v1beta1";
const SANDBOX_API_VERSION_V1ALPHA1: &str = "v1alpha1";
const SANDBOX_API_VERSION_FULL_V1BETA1: &str = "agents.x-k8s.io/v1beta1";
const SANDBOX_API_VERSION_FULL_V1ALPHA1: &str = "agents.x-k8s.io/v1alpha1";
const APPS_API_VERSION_FULL_V1: &str = "apps/v1";
const SANDBOX_KIND: &str = "Sandbox";
const REPLICA_SET_KIND: &str = "ReplicaSet";
const DEPLOYMENT_KIND: &str = "Deployment";
const SANDBOX_ID_LABEL: &str = "openshell.ai/sandbox-id";
const POD_NAME_EXTRA: &str = "authentication.kubernetes.io/pod-name";
const POD_UID_EXTRA: &str = "authentication.kubernetes.io/pod-uid";
@@ -148,11 +153,21 @@ struct SandboxOwnerReference {
uid: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ControllerOwnerReference {
api_version: String,
kind: String,
name: String,
uid: String,
}
/// Resolver backed by the apiserver's `TokenReview` API and `kube::Client`
/// for the per-pod annotation lookup.
pub struct LiveK8sResolver {
token_reviews_api: Api<TokenReview>,
pods_api: Api<Pod>,
replica_sets_api: Api<ReplicaSet>,
deployments_api: Api<Deployment>,
sandboxes_api_v1beta1: Api<DynamicObject>,
sandboxes_api_v1alpha1: Api<DynamicObject>,
expected_audience: String,
@@ -169,6 +184,8 @@ impl LiveK8sResolver {
) -> Self {
let token_reviews_api: Api<TokenReview> = Api::all(client.clone());
let pods_api: Api<Pod> = Api::namespaced(client.clone(), namespace);
let replica_sets_api: Api<ReplicaSet> = Api::namespaced(client.clone(), namespace);
let deployments_api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
let sandbox_gvk_v1beta1 =
GroupVersionKind::gvk(SANDBOX_API_GROUP, SANDBOX_API_VERSION_V1BETA1, SANDBOX_KIND);
let sandbox_resource_v1beta1 = ApiResource::from_gvk(&sandbox_gvk_v1beta1);
@@ -185,6 +202,8 @@ impl LiveK8sResolver {
Self {
token_reviews_api,
pods_api,
replica_sets_api,
deployments_api,
sandboxes_api_v1beta1,
sandboxes_api_v1alpha1,
expected_audience,
@@ -214,6 +233,129 @@ impl LiveK8sResolver {
Ok(None)
}
async fn sandbox_owner_for_pod(
&self,
pod: &Pod,
pod_name: &str,
) -> Result<SandboxOwnerReference, Status> {
match direct_sandbox_owner_reference(pod) {
Ok(owner) => Ok(owner),
Err(err) => {
let Some(controller) = controller_owner_reference(
pod.metadata.owner_references.as_deref().unwrap_or_default(),
) else {
return Err(err);
};
if controller.api_version != APPS_API_VERSION_FULL_V1
|| controller.kind != REPLICA_SET_KIND
{
return Err(err);
}
self.sandbox_owner_for_replica_set_controller(&controller, pod_name)
.await
}
}
}
async fn sandbox_owner_for_replica_set_controller(
&self,
replica_set_owner: &ControllerOwnerReference,
pod_name: &str,
) -> Result<SandboxOwnerReference, Status> {
let replica_set = self
.replica_sets_api
.get_opt(&replica_set_owner.name)
.await
.map_err(|e| {
warn!(
pod = %pod_name,
replica_set = %replica_set_owner.name,
error = %e,
"failed to fetch ReplicaSet for pod identity validation"
);
Status::internal(format!("replicaset GET failed: {e}"))
})?
.ok_or_else(|| {
warn!(
pod = %pod_name,
replica_set = %replica_set_owner.name,
"pod controller ReplicaSet was not found"
);
Status::permission_denied("pod controller ReplicaSet not found")
})?;
validate_object_uid(
replica_set.metadata.uid.as_deref().unwrap_or_default(),
&replica_set_owner.uid,
"pod controller ReplicaSet UID mismatch",
)?;
let deployment_owner = controller_owner_reference(
replica_set
.metadata
.owner_references
.as_deref()
.unwrap_or_default(),
)
.ok_or_else(|| {
warn!(
pod = %pod_name,
replica_set = %replica_set_owner.name,
"ReplicaSet has no controlling Deployment ownerReference"
);
Status::permission_denied("ReplicaSet is not controlled by a Deployment")
})?;
if deployment_owner.api_version != APPS_API_VERSION_FULL_V1
|| deployment_owner.kind != DEPLOYMENT_KIND
{
warn!(
pod = %pod_name,
replica_set = %replica_set_owner.name,
owner_api_version = %deployment_owner.api_version,
owner_kind = %deployment_owner.kind,
"ReplicaSet controller is not an apps/v1 Deployment"
);
return Err(Status::permission_denied(
"ReplicaSet is not controlled by a Deployment",
));
}
let deployment = self
.deployments_api
.get_opt(&deployment_owner.name)
.await
.map_err(|e| {
warn!(
pod = %pod_name,
deployment = %deployment_owner.name,
error = %e,
"failed to fetch Deployment for pod identity validation"
);
Status::internal(format!("deployment GET failed: {e}"))
})?
.ok_or_else(|| {
warn!(
pod = %pod_name,
deployment = %deployment_owner.name,
"ReplicaSet controller Deployment was not found"
);
Status::permission_denied("ReplicaSet controller Deployment not found")
})?;
validate_object_uid(
deployment.metadata.uid.as_deref().unwrap_or_default(),
&deployment_owner.uid,
"ReplicaSet controller Deployment UID mismatch",
)?;
sandbox_owner_reference_from_owner_refs(
deployment
.metadata
.owner_references
.as_deref()
.unwrap_or_default(),
"Deployment",
)
}
}
#[async_trait]
@@ -293,7 +435,7 @@ impl K8sIdentityResolver for LiveK8sResolver {
let sandbox_id = pod_sandbox_id(&pod)?;
let owner = sandbox_owner_reference(&pod)?;
let owner = self.sandbox_owner_for_pod(&pod, &identity.pod_name).await?;
let sandbox_cr = self.get_sandbox_cr_for_owner(&owner).await.map_err(|e| {
warn!(
pod = %identity.pod_name,
@@ -406,8 +548,18 @@ fn pod_sandbox_id(pod: &Pod) -> Result<String, Status> {
}
#[allow(clippy::result_large_err)]
fn sandbox_owner_reference(pod: &Pod) -> Result<SandboxOwnerReference, Status> {
let owner_refs = pod.metadata.owner_references.as_deref().unwrap_or_default();
fn direct_sandbox_owner_reference(pod: &Pod) -> Result<SandboxOwnerReference, Status> {
sandbox_owner_reference_from_owner_refs(
pod.metadata.owner_references.as_deref().unwrap_or_default(),
"pod",
)
}
#[allow(clippy::result_large_err)]
fn sandbox_owner_reference_from_owner_refs(
owner_refs: &[OwnerReference],
object_kind: &str,
) -> Result<SandboxOwnerReference, Status> {
let mut sandbox_refs = owner_refs
.iter()
.filter(|owner| is_supported_sandbox_owner_reference(owner));
@@ -424,27 +576,28 @@ fn sandbox_owner_reference(pod: &Pod) -> Result<SandboxOwnerReference, Status> {
SANDBOX_API_VERSION_FULL_V1BETA1,
SANDBOX_API_VERSION_FULL_V1ALPHA1,
],
"pod Sandbox ownerReference uses unsupported apiVersion"
object_kind = %object_kind,
"Sandbox ownerReference uses unsupported apiVersion"
);
}
return Err(Status::permission_denied(
"pod is not controlled by an OpenShell Sandbox",
));
return Err(Status::permission_denied(format!(
"{object_kind} is not controlled by an OpenShell Sandbox"
)));
};
if sandbox_refs.next().is_some() {
return Err(Status::permission_denied(
"pod has multiple OpenShell Sandbox owners",
));
return Err(Status::permission_denied(format!(
"{object_kind} has multiple OpenShell Sandbox owners"
)));
}
if owner.controller != Some(true) {
return Err(Status::permission_denied(
"pod Sandbox ownerReference is not controlling",
));
return Err(Status::permission_denied(format!(
"{object_kind} Sandbox ownerReference is not controlling"
)));
}
if owner.name.is_empty() || owner.uid.is_empty() {
return Err(Status::permission_denied(
"pod Sandbox ownerReference is incomplete",
));
return Err(Status::permission_denied(format!(
"{object_kind} Sandbox ownerReference is incomplete"
)));
}
Ok(SandboxOwnerReference {
api_version: owner.api_version.clone(),
@@ -453,9 +606,32 @@ fn sandbox_owner_reference(pod: &Pod) -> Result<SandboxOwnerReference, Status> {
})
}
fn is_supported_sandbox_owner_reference(
owner: &k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference,
) -> bool {
fn controller_owner_reference(owner_refs: &[OwnerReference]) -> Option<ControllerOwnerReference> {
let owner = owner_refs
.iter()
.find(|owner| owner.controller == Some(true))?;
Some(ControllerOwnerReference {
api_version: owner.api_version.clone(),
kind: owner.kind.clone(),
name: owner.name.clone(),
uid: owner.uid.clone(),
})
}
#[allow(clippy::result_large_err)]
fn validate_object_uid(actual_uid: &str, expected_uid: &str, message: &str) -> Result<(), Status> {
if actual_uid != expected_uid {
warn!(
expected_uid = %expected_uid,
actual_uid = %actual_uid,
%message
);
return Err(Status::permission_denied(message.to_string()));
}
Ok(())
}
fn is_supported_sandbox_owner_reference(owner: &OwnerReference) -> bool {
owner.kind == SANDBOX_KIND
&& matches!(
owner.api_version.as_str(),
@@ -629,6 +805,17 @@ mod tests {
}
}
fn app_controller_owner(kind: &str, name: &str, uid: &str) -> OwnerReference {
OwnerReference {
api_version: APPS_API_VERSION_FULL_V1.to_string(),
block_owner_deletion: None,
controller: Some(true),
kind: kind.to_string(),
name: name.to_string(),
uid: uid.to_string(),
}
}
fn pod_with_owner_refs(owner_references: Vec<OwnerReference>) -> Pod {
Pod {
metadata: ObjectMeta {
@@ -780,7 +967,7 @@ mod tests {
fn sandbox_owner_reference_extracts_controlling_sandbox_owner() {
let pod = pod_with_owner_refs(vec![sandbox_owner("sandbox-a", "cr-uid-a")]);
let owner = sandbox_owner_reference(&pod).expect("expected Sandbox owner");
let owner = direct_sandbox_owner_reference(&pod).expect("expected Sandbox owner");
assert_eq!(
owner,
@@ -800,7 +987,7 @@ mod tests {
"cr-uid-a",
)]);
let owner = sandbox_owner_reference(&pod).expect("expected v1alpha1 Sandbox owner");
let owner = direct_sandbox_owner_reference(&pod).expect("expected v1alpha1 Sandbox owner");
assert_eq!(
owner,
@@ -816,7 +1003,7 @@ mod tests {
fn sandbox_owner_reference_rejects_missing_owner() {
let pod = pod_with_owner_refs(vec![]);
let err = sandbox_owner_reference(&pod).expect_err("missing owner must fail");
let err = direct_sandbox_owner_reference(&pod).expect_err("missing owner must fail");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}
@@ -829,8 +1016,8 @@ mod tests {
"cr-uid-a",
)]);
let err =
sandbox_owner_reference(&pod).expect_err("unsupported apiVersion must fail closed");
let err = direct_sandbox_owner_reference(&pod)
.expect_err("unsupported apiVersion must fail closed");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}
@@ -841,7 +1028,7 @@ mod tests {
owner.controller = Some(false);
let pod = pod_with_owner_refs(vec![owner]);
let err = sandbox_owner_reference(&pod).expect_err("non-controller owner must fail");
let err = direct_sandbox_owner_reference(&pod).expect_err("non-controller owner must fail");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}
@@ -853,11 +1040,50 @@ mod tests {
sandbox_owner("sandbox-b", "cr-uid-b"),
]);
let err = sandbox_owner_reference(&pod).expect_err("multiple owners must fail");
let err = direct_sandbox_owner_reference(&pod).expect_err("multiple owners must fail");
assert_eq!(err.code(), tonic::Code::PermissionDenied);
}
#[test]
fn controller_owner_reference_extracts_controlling_apps_owner() {
let pod = pod_with_owner_refs(vec![app_controller_owner(
REPLICA_SET_KIND,
"supervisor-rs",
"rs-uid",
)]);
let owner = controller_owner_reference(pod.metadata.owner_references.as_deref().unwrap())
.expect("expected controller owner");
assert_eq!(
owner,
ControllerOwnerReference {
api_version: APPS_API_VERSION_FULL_V1.to_string(),
kind: REPLICA_SET_KIND.to_string(),
name: "supervisor-rs".to_string(),
uid: "rs-uid".to_string(),
}
);
}
#[test]
fn sandbox_owner_reference_from_deployment_requires_controlling_sandbox_owner() {
let deployment_owner_refs = vec![sandbox_owner("sandbox-a", "cr-uid-a")];
let owner = sandbox_owner_reference_from_owner_refs(&deployment_owner_refs, "Deployment")
.expect("expected Deployment Sandbox owner");
assert_eq!(
owner,
SandboxOwnerReference {
api_version: SANDBOX_API_VERSION_FULL_V1BETA1.to_string(),
name: "sandbox-a".to_string(),
uid: "cr-uid-a".to_string(),
}
);
}
#[test]
fn validate_sandbox_owner_reference_requires_matching_cr_uid_and_label() {
let owner = SandboxOwnerReference {
+1 -1
View File
@@ -238,7 +238,7 @@ add `ci/values-spire.yaml` to the OpenShell release values files.
| supervisor.image.tag | string | `""` | Supervisor image tag. Defaults to the chart appVersion when empty. |
| supervisor.sidecarProxyUid | int | `1337` | UID for the long-running network sidecar/split supervisor. In sidecar topology, the network init container installs nftables rules that exempt this UID. |
| supervisor.sideloadMethod | string | `""` | How the supervisor binary is delivered into sandbox pods. Empty (default) = auto-detect from cluster version: K8s >= v1.35 -> "image-volume" (ImageVolume enabled by default; GA in v1.36) K8s < v1.35 -> "init-container" (copies via init container + emptyDir) On K8s v1.33-v1.34 with the ImageVolume feature gate manually enabled, set this to "image-volume" explicitly. |
| supervisor.topology | string | `"combined"` | Supervisor pod topology for Kubernetes sandboxes. "combined" runs the current single supervisor container in the agent pod. "sidecar" runs network enforcement in a dedicated sidecar and the process supervisor as a low-capability wrapper in the agent container. "split-pod" runs network enforcement in a separate supervisor pod and restricts the agent pod to that supervisor through NetworkPolicy. |
| supervisor.topology | string | `"combined"` | Supervisor pod topology for Kubernetes sandboxes. "combined" runs the current single supervisor container in the agent pod. "sidecar" runs network enforcement in a dedicated sidecar and the process supervisor as a low-capability wrapper in the agent container. "split-pod" runs network enforcement in a separate supervisor Deployment and restricts the agent pod to that supervisor through NetworkPolicy. |
| tolerations | list | `[]` | Tolerations for the gateway pod. |
| workload.allowMultiReplicaStatefulSet | bool | `false` | Allow replicaCount > 1 while rendering a StatefulSet. Prefer workload.kind=deployment for external database-backed multi-replica gateways; this override exists for operators who explicitly require StatefulSet identity or storage semantics. |
| workload.kind | string | `"statefulset"` | Gateway workload controller kind. Use `statefulset` for the default SQLite database, or `deployment` when server.externalDbSecret points at an external database. |
+20 -3
View File
@@ -35,19 +35,36 @@ rules:
# returned pod name and UID to the pod's `openshell.io/sandbox-id`
# annotation. patch is intentionally NOT granted — the annotation is set
# once at pod create and must remain immutable for the lifetime of the
# sandbox.
# sandbox. create/delete/list/watch are intentionally not granted; the Agent
# Sandbox controller creates agent pods, and split-pod supervisors are
# managed through per-sandbox Deployments.
- apiGroups:
- ""
resources:
- pods
verbs:
- get
# Split-pod topology creates one supervisor Deployment, one supervisor
# Service, and one CA Secret per sandbox. All are owner-referenced to the
# Sandbox CR for garbage collection. The gateway also reads the generated
# ReplicaSet during K8s ServiceAccount bootstrap to verify the supervisor
# pod's Pod -> ReplicaSet -> Deployment -> Sandbox owner chain.
- apiGroups:
- apps
resources:
- deployments
verbs:
- create
- delete
- get
- list
- watch
# Split-pod topology creates one supervisor Service and one CA Secret per
# sandbox. Both are owner-referenced to the Sandbox CR for garbage collection.
- apiGroups:
- apps
resources:
- replicasets
verbs:
- get
- apiGroups:
- ""
resources:
@@ -57,6 +57,49 @@ tests:
path: metadata.namespace
value: other-ns
- it: grants only pod get for sandbox token bootstrap
template: templates/role.yaml
asserts:
- contains:
path: rules
content:
apiGroups:
- ""
resources:
- pods
verbs:
- get
- it: grants sandbox RBAC for split-pod supervisor Deployments
template: templates/role.yaml
asserts:
- contains:
path: rules
content:
apiGroups:
- apps
resources:
- deployments
verbs:
- create
- delete
- get
- list
- watch
- it: grants ReplicaSet get for split-pod supervisor token bootstrap
template: templates/role.yaml
asserts:
- contains:
path: rules
content:
apiGroups:
- apps
resources:
- replicasets
verbs:
- get
- it: uses explicit sandboxNamespace for sandbox RoleBinding
template: templates/rolebinding.yaml
set:
+1 -1
View File
@@ -48,7 +48,7 @@ supervisor:
# "combined" runs the current single supervisor container in the agent pod.
# "sidecar" runs network enforcement in a dedicated sidecar and the process
# supervisor as a low-capability wrapper in the agent container.
# "split-pod" runs network enforcement in a separate supervisor pod and
# "split-pod" runs network enforcement in a separate supervisor Deployment and
# restricts the agent pod to that supervisor through NetworkPolicy.
topology: "combined"
# -- UID for the long-running network sidecar/split supervisor. In sidecar
+4
View File
@@ -215,6 +215,10 @@ The namespaced Role covers sandbox lifecycle and identity:
| `agents.x-k8s.io` | `sandboxes`, `sandboxes/status` | create, delete, get, list, patch, update, watch |
| `""` | `events` | get, list, watch |
| `""` | `pods` | get |
| `apps` | `deployments` | create, delete, get, list, watch |
| `apps` | `replicasets` | get |
| `""` | `services`, `secrets` | create, delete, get, list, watch |
| `networking.k8s.io` | `networkpolicies` | create, delete, get, list, watch |
The ClusterRole grants node inspection and token validation:
+7 -6
View File
@@ -99,20 +99,21 @@ dropping, or process/binary identity checks.
## Split-Pod Topology
Split-pod topology moves network enforcement and gateway forwarding into a
separate supervisor pod. The agent pod runs the process supervisor and reaches
the supervisor through a per-sandbox headless Service.
separate supervisor Deployment with one pod. The agent pod runs the process
supervisor and reaches the supervisor through a per-sandbox headless Service.
OpenShell creates these per-sandbox resources:
- Agent pod labeled `openshell.ai/sandbox-role=agent`.
- Supervisor pod labeled `openshell.ai/sandbox-role=supervisor`.
- Supervisor Deployment with one pod labeled `openshell.ai/sandbox-role=supervisor`.
- Headless Service for the supervisor pod.
- Proxy CA Secret shared through mounts.
- NetworkPolicy that limits agent egress to the supervisor pod and DNS.
- NetworkPolicy that accepts supervisor ingress only from the paired agent pod.
The supervisor pod has a controlling `Sandbox` ownerReference so Kubernetes
garbage collection removes the companion resources when the sandbox is deleted.
The supervisor Deployment has a controlling `Sandbox` ownerReference so
Kubernetes garbage collection removes it when the sandbox is deleted. The
Deployment recreates the supervisor pod if the pod is deleted independently.
<Warning>
Split-pod topology requires NetworkPolicy enforcement. In local k3s/k3d testing,
@@ -175,7 +176,7 @@ supervisor:
```
The same `sidecarProxyUid` value is used as the non-root UID for the split
supervisor pod and must not match the sandbox UID.
supervisor pod created by the Deployment and must not match the sandbox UID.
For direct gateway TOML configuration, set the equivalent Kubernetes driver
fields:
+1 -1
View File
@@ -181,7 +181,7 @@ supervisor_sideload_method = "image-volume"
# filesystem, and network enforcement in the agent container. "sidecar" moves
# pod-level network enforcement and gateway forwarding into a network sidecar.
# "split-pod" moves network enforcement and gateway forwarding into a separate
# supervisor pod and uses NetworkPolicy to force agent egress through it. In
# supervisor Deployment and uses NetworkPolicy to force agent egress through it. In
# sidecar and split-pod modes, the agent container runs non-root with no added
# Linux capabilities and process/filesystem enforcement is network-only.
supervisor_topology = "combined"
+6 -5
View File
@@ -304,7 +304,7 @@ For maintainer-level implementation details, refer to the [Kubernetes driver REA
| `supervisor_image` | `supervisor.image.repository` / `supervisor.image.tag` | Set the supervisor image that provides the `openshell-sandbox` binary. |
| `supervisor_image_pull_policy` | `supervisor.image.pullPolicy` | Set the Kubernetes image pull policy for the supervisor image. |
| `supervisor_sideload_method` | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect from cluster version. Set to `image-volume` to mount the supervisor OCI image directly as a volume (requires Kubernetes 1.33+ with the ImageVolume feature gate; GA in 1.36), or `init-container` to copy it through an init container on older clusters. |
| `supervisor_topology` | `supervisor.topology` | Set `combined` for the default single supervisor path, `sidecar` to move pod-level network enforcement and gateway forwarding into a dedicated sidecar, or `split-pod` to run network enforcement in a separate supervisor pod with NetworkPolicy isolation. |
| `supervisor_topology` | `supervisor.topology` | Set `combined` for the default single supervisor path, `sidecar` to move pod-level network enforcement and gateway forwarding into a dedicated sidecar, or `split-pod` to run network enforcement in a separate supervisor Deployment with NetworkPolicy isolation. |
| `sidecar_proxy_uid` | `supervisor.sidecarProxyUid` | UID used by the long-running network sidecar or split supervisor pod. In `sidecar` topology, the network init container exempts this UID from proxy redirection. |
| `app_armor_profile` | `server.appArmorProfile` | Set the sandbox agent container's AppArmor profile. Helm defaults this to `Unconfined` so AppArmor-enabled nodes do not block supervisor network namespace setup. Set the Helm value to an empty string to omit the field, or use `RuntimeDefault` or `Localhost/<profile-name>` for operator-managed profiles. |
| `workspace_default_storage_size` | `server.workspaceDefaultStorageSize` | Set the default workspace PVC size for new sandboxes. |
@@ -316,10 +316,11 @@ policy, process privilege changes, and network policy enforcement. In `sidecar`
and `split-pod` topology, the agent container runs as the resolved sandbox
UID/GID with no added Linux capabilities. Sidecar mode uses a root init
container for nftables setup and a long-running non-root sidecar. Split-pod mode
creates a separate non-root supervisor pod, a headless Service, a proxy CA
Secret, and per-sandbox NetworkPolicies. Both modes keep gateway session and SSH
behavior, but the process supervisor runs in network-only mode: filesystem
policy, process privilege dropping, and
creates a separate non-root supervisor Deployment with one pod, a headless
Service, a proxy CA Secret, and per-sandbox NetworkPolicies. The Deployment
recreates the supervisor pod if it is deleted. Both modes keep gateway session
and SSH behavior, but the process supervisor runs in network-only mode:
filesystem policy, process privilege dropping, and
process/binary identity checks are not applied by the process container.
Sidecar mode uses pod `fsGroup` so the non-root process supervisor can read the