Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .agents/skills/helm-dev-environment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ every member one at a time. The PID must survive each case, while an open
replacement confirmed within the recovery timeout, so the runtime terminated
the workload.

For RFC 0017 app-listener validation, enable the listener and supply the app TLS
Secret on the shared Deployment, plus a separate NetworkPolicy allowing the test
client/proxy to reach its app port. Runtime-port access does not grant app access.
Discovery returns the acknowledged owner's Pod IP and app port; no headless app
Service is needed. Exercise HTTP/WebSocket reconnection through graceful downscale
and abrupt loss, preserving workload generation/PID/in-memory state and the public
hostname. Verify stale-owner rejection independently of the replacement's success.
Shared serving leases expire within fifteen seconds of issuance and do not renew
from configuration alone. Do not claim seamless socket migration or infer external
ingress readiness from an advertisement.

### 2. Deploy OpenShell

**Iterative dev** (rebuilds on file changes, recommended during active development):
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-core/src/app_listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ impl AppListenerConfig {
}
}

/// A synchronous view of installed ingress, evaluated on the sandbox instance
/// runtime. The requested lease must fit entirely within serving freshness.
pub type InstallationSource = std::sync::Arc<
dyn Fn(std::time::Duration) -> crate::proto::SupervisorServiceAdvertisement + Send + Sync,
>;

#[cfg(test)]
mod tests {
use super::*;
Expand Down
24 changes: 24 additions & 0 deletions crates/openshell-driver-docker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3187,6 +3187,18 @@ impl ComputeDriver for ComputeDriverService {
.await
}

async fn resolve_supervisor_app_endpoint(
&self,
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
) -> Result<
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
Status,
> {
Err(Status::unimplemented(
"supervisor application endpoint resolution is not supported",
))
}

async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
Expand Down Expand Up @@ -3358,6 +3370,18 @@ impl ComputeDriver for DockerComputeDriver {
Ok(Response::new(ValidateSandboxCreateResponse {}))
}

async fn resolve_supervisor_app_endpoint(
&self,
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
) -> Result<
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
Status,
> {
Err(Status::unimplemented(
"supervisor application endpoint resolution is not supported",
))
}

async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
Expand Down
195 changes: 195 additions & 0 deletions crates/openshell-driver-kubernetes/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,201 @@ impl KubernetesComputeDriver {
Ok(())
}

/// Resolve only a unique, live driver-owned supervisor. Multi-member pool
/// resolution must use the HA assignment/member API rather than guess.
pub async fn supervisor_app_endpoint(
&self,
sandbox_id: &str,
) -> Result<
Option<openshell_core::proto::compute::v1::SupervisorAppEndpoint>,
KubernetesDriverError,
> {
if !self.config.sandbox_runtime.app_listener.common.enabled {
return Ok(None);
}
let api = self
.supported_sandbox_api_for_lookup(self.client.clone())
.await
.map_err(KubernetesDriverError::Message)?;
let objects = api
.api
.list(&ListParams::default().labels(&self.sandbox_lookup_selector(sandbox_id)))
.await
.map_err(KubernetesDriverError::from_kube)?
.items;
let [object]: [DynamicObject; 1] = match objects.try_into() {
Ok(objects) => objects,
Err(_) => return Ok(None),
};
let namespace = object.metadata.namespace.as_deref().ok_or_else(|| {
KubernetesDriverError::Precondition("sandbox namespace missing".into())
})?;
let uid = object
.metadata
.uid
.as_deref()
.ok_or_else(|| KubernetesDriverError::Precondition("sandbox UID missing".into()))?;
let placement = supervisor_placement(&object)?;
let mut pool_route = String::new();
let mut ownership_epoch = 0;
let mut member_address = None;
let pods = if let SupervisorPlacement::Shared { route } = &placement {
let Some(shared) = &self.shared_supervisor else {
return Ok(None);
};
let controller = shared.supervision().controller();
let Some(view) = controller.pool_view() else {
return Ok(None);
};
let Some(owner) = view
.owners
.get(route)
.filter(|_| view.desired.contains(route))
else {
return Ok(None);
};
let Some(member) = view.members.iter().find(|member| {
member.member_id == owner.member_id
&& member.process_id == owner.process_id
&& !member.leaving
}) else {
return Ok(None);
};
if !matches!(controller.observed(route),
openshell_supervisor_control::ObservedInstance::Reported(status)
if status.sandbox_id == sandbox_id && status.ready
&& status.phase == openshell_supervisor_control::proto::SandboxInstancePhase::Supervising as i32)
{
return Ok(None);
}
let address = member
.runtime_address
.parse::<std::net::SocketAddr>()
.map_err(|_| {
KubernetesDriverError::Precondition(
"pool member runtime address is not an IP endpoint".into(),
)
})?;
let pod =
Api::<Pod>::namespaced(self.client.clone(), &shared.config().service_namespace)
.get_opt(&owner.member_id)
.await
.map_err(KubernetesDriverError::from_kube)?;
let Some(pod) = pod else {
return Ok(None);
};
let service =
Api::<Service>::namespaced(self.client.clone(), &shared.config().service_namespace)
.get(&shared.config().service_name)
.await
.map_err(KubernetesDriverError::from_kube)?;
let Some(selector) = service
.spec
.as_ref()
.and_then(|spec| spec.selector.as_ref())
.filter(|selector| !selector.is_empty())
else {
return Ok(None);
};
if !selector.iter().all(|(key, value)| {
pod.metadata
.labels
.as_ref()
.and_then(|labels| labels.get(key))
== Some(value)
}) {
return Ok(None);
}
pool_route = route.clone();
ownership_epoch = owner.auth_epoch;
member_address = Some(address.ip());
vec![pod]
} else {
let selector =
format!("openshell.ai/sandbox-id={sandbox_id},openshell.ai/component=supervisor");
Api::<Pod>::namespaced(self.client.clone(), namespace)
.list(&ListParams::default().labels(&selector))
.await
.map_err(KubernetesDriverError::from_kube)?
.items
};
// Includes terminating members in the ambiguity check; never guess a
// replacement's ownership during a rollout.
let [pod]: [Pod; 1] = match pods.try_into() {
Ok(pods) => pods,
Err(_) => return Ok(None),
};
if pod.metadata.deletion_timestamp.is_some() {
return Ok(None);
}
let app_port = i32::from(self.config.sandbox_runtime.app_listener.common.port);
if !pod
.spec
.as_ref()
.and_then(|spec| {
spec.containers
.iter()
.find(|container| container.name == "supervisor")
})
.is_some_and(|container| {
container.ports.as_ref().is_some_and(|ports| {
ports.iter().any(|port| {
port.name.as_deref() == Some("app") && port.container_port == app_port
})
}) && container.env.as_ref().is_some_and(|env| {
env.iter().any(|entry| {
entry.name == "OPENSHELL_APP_LISTENER_ENABLED"
&& entry.value.as_deref() == Some("true")
})
})
})
{
return Ok(None);
}
let Some(pod_uid) = pod.metadata.uid.as_deref() else {
return Ok(None);
};
if matches!(placement, SupervisorPlacement::Dedicated)
&& !pod
.metadata
.owner_references
.as_ref()
.is_some_and(|owners| owners.iter().any(|o| o.uid == uid && o.kind == "Sandbox"))
{
return Ok(None);
}
let Some(status) = pod.status else {
return Ok(None);
};
if status.phase.as_deref() != Some("Running") {
return Ok(None);
}
let Some(host) = status
.pod_ip
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok())
else {
return Ok(None);
};
if member_address
.is_some_and(|address| host.parse::<std::net::IpAddr>().ok() != Some(address))
{
return Ok(None);
}
let component = match placement {
SupervisorPlacement::Dedicated => pod_uid.to_string(),
SupervisorPlacement::Shared { route } => route,
};
Ok(Some(
openshell_core::proto::compute::v1::SupervisorAppEndpoint {
host,
port: u32::from(self.config.sandbox_runtime.app_listener.common.port),
runtime_identity: kubernetes_runtime_identity(namespace, uid, &component),
pool_route,
ownership_epoch,
},
))
}

pub async fn get_sandbox(&self, sandbox_id: &str) -> Result<Option<Sandbox>, String> {
info!(
sandbox_id = %sandbox_id,
Expand Down
31 changes: 31 additions & 0 deletions crates/openshell-driver-kubernetes/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,37 @@ impl ComputeDriver for ComputeDriverService {
.await
}

async fn resolve_supervisor_app_endpoint(
&self,
request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
) -> Result<
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
Status,
> {
self.rpc_tracer
.trace(
openshell_otel::rpc::RESOLVE_SUPERVISOR_APP_ENDPOINT,
async {
let request = request.into_inner();
if request.sandbox_id.is_empty() {
return Err(Status::invalid_argument("sandbox_id is required"));
}
let endpoint = self
.driver
.supervisor_app_endpoint(&request.sandbox_id)
.await
.map_err(|e| Status::unavailable(e.to_string()))?;
Ok(Response::new(
openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse {
sandbox_id: request.sandbox_id,
supervisor_app_endpoint: endpoint,
},
))
},
)
.await
}

async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
Expand Down
12 changes: 12 additions & 0 deletions crates/openshell-driver-mxc/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ impl ComputeDriver for ComputeDriverService {
Ok(Response::new(ValidateSandboxCreateResponse {}))
}

async fn resolve_supervisor_app_endpoint(
&self,
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
) -> Result<
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
Status,
> {
Err(Status::unimplemented(
"supervisor application endpoint resolution is not supported",
))
}

async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
Expand Down
12 changes: 12 additions & 0 deletions crates/openshell-driver-podman/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,18 @@ impl ComputeDriver for ComputeDriverService {
.await
}

async fn resolve_supervisor_app_endpoint(
&self,
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
) -> Result<
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
Status,
> {
Err(Status::unimplemented(
"supervisor application endpoint resolution is not supported",
))
}

async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
Expand Down
12 changes: 12 additions & 0 deletions crates/openshell-driver-vm/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5362,6 +5362,18 @@ impl ComputeDriver for VmDriver {
Ok(Response::new(response))
}

async fn resolve_supervisor_app_endpoint(
&self,
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
) -> Result<
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
Status,
> {
Err(Status::unimplemented(
"supervisor application endpoint resolution is not supported",
))
}

async fn get_sandbox(
&self,
request: Request<GetSandboxRequest>,
Expand Down
6 changes: 6 additions & 0 deletions crates/openshell-otel/src/grpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ pub mod rpc {
"GetSandbox",
"openshell.compute.v1.ComputeDriver/GetSandbox",
);
pub const RESOLVE_SUPERVISOR_APP_ENDPOINT: ComputeDriverRpc = ComputeDriverRpc::new(
"ResolveSupervisorAppEndpoint",
"openshell.compute.v1.ComputeDriver/ResolveSupervisorAppEndpoint",
);
pub const LIST_SANDBOXES: ComputeDriverRpc = ComputeDriverRpc::new(
"ListSandboxes",
"openshell.compute.v1.ComputeDriver/ListSandboxes",
Expand Down Expand Up @@ -195,6 +199,7 @@ pub fn compute_driver_rpc_operation(path: &str) -> Option<ComputeDriverRpc> {
Some("ValidateSandboxCreate") => Some(rpc::VALIDATE_SANDBOX_CREATE),
Some("CreateSandbox") => Some(rpc::CREATE_SANDBOX),
Some("GetSandbox") => Some(rpc::GET_SANDBOX),
Some("ResolveSupervisorAppEndpoint") => Some(rpc::RESOLVE_SUPERVISOR_APP_ENDPOINT),
Some("ListSandboxes") => Some(rpc::LIST_SANDBOXES),
Some("StopSandbox") => Some(rpc::STOP_SANDBOX),
Some("StartSandbox") => Some(rpc::START_SANDBOX),
Expand Down Expand Up @@ -318,6 +323,7 @@ mod tests {
rpc::VALIDATE_SANDBOX_CREATE,
rpc::CREATE_SANDBOX,
rpc::GET_SANDBOX,
rpc::RESOLVE_SUPERVISOR_APP_ENDPOINT,
rpc::LIST_SANDBOXES,
rpc::STOP_SANDBOX,
rpc::START_SANDBOX,
Expand Down
Loading
Loading