Skip to content

Commit 0ce10f0

Browse files
committed
feat(supervisor): advertise accepted application route installations
Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
1 parent d94fb6a commit 0ce10f0

36 files changed

Lines changed: 2640 additions & 468 deletions

File tree

‎.agents/skills/helm-dev-environment/SKILL.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,17 @@ every member one at a time. The PID must survive each case, while an open
195195
replacement confirmed within the recovery timeout, so the runtime terminated
196196
the workload.
197197

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

200211
**Iterative dev** (rebuilds on file changes, recommended during active development):

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ impl AppListenerConfig {
5050
}
5151
}
5252

53+
/// A synchronous view of installed ingress, evaluated on the sandbox instance
54+
/// runtime. The requested lease must fit entirely within serving freshness.
55+
pub type InstallationSource = std::sync::Arc<
56+
dyn Fn(std::time::Duration) -> crate::proto::SupervisorServiceAdvertisement + Send + Sync,
57+
>;
58+
5359
#[cfg(test)]
5460
mod tests {
5561
use super::*;

‎crates/openshell-driver-docker/src/lib.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3197,6 +3197,18 @@ impl ComputeDriver for ComputeDriverService {
31973197
.await
31983198
}
31993199

3200+
async fn resolve_supervisor_app_endpoint(
3201+
&self,
3202+
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
3203+
) -> Result<
3204+
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
3205+
Status,
3206+
> {
3207+
Err(Status::unimplemented(
3208+
"supervisor application endpoint resolution is not supported",
3209+
))
3210+
}
3211+
32003212
async fn get_sandbox(
32013213
&self,
32023214
request: Request<GetSandboxRequest>,
@@ -3368,6 +3380,18 @@ impl ComputeDriver for DockerComputeDriver {
33683380
Ok(Response::new(ValidateSandboxCreateResponse {}))
33693381
}
33703382

3383+
async fn resolve_supervisor_app_endpoint(
3384+
&self,
3385+
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
3386+
) -> Result<
3387+
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
3388+
Status,
3389+
> {
3390+
Err(Status::unimplemented(
3391+
"supervisor application endpoint resolution is not supported",
3392+
))
3393+
}
3394+
33713395
async fn get_sandbox(
33723396
&self,
33733397
request: Request<GetSandboxRequest>,

‎crates/openshell-driver-kubernetes/src/driver.rs‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2235,6 +2235,201 @@ impl KubernetesComputeDriver {
22352235
Ok(())
22362236
}
22372237

2238+
/// Resolve only a unique, live driver-owned supervisor. Multi-member pool
2239+
/// resolution must use the HA assignment/member API rather than guess.
2240+
pub async fn supervisor_app_endpoint(
2241+
&self,
2242+
sandbox_id: &str,
2243+
) -> Result<
2244+
Option<openshell_core::proto::compute::v1::SupervisorAppEndpoint>,
2245+
KubernetesDriverError,
2246+
> {
2247+
if !self.config.sandbox_runtime.app_listener.common.enabled {
2248+
return Ok(None);
2249+
}
2250+
let api = self
2251+
.supported_sandbox_api_for_lookup(self.client.clone())
2252+
.await
2253+
.map_err(KubernetesDriverError::Message)?;
2254+
let objects = api
2255+
.api
2256+
.list(&ListParams::default().labels(&self.sandbox_lookup_selector(sandbox_id)))
2257+
.await
2258+
.map_err(KubernetesDriverError::from_kube)?
2259+
.items;
2260+
let [object]: [DynamicObject; 1] = match objects.try_into() {
2261+
Ok(objects) => objects,
2262+
Err(_) => return Ok(None),
2263+
};
2264+
let namespace = object.metadata.namespace.as_deref().ok_or_else(|| {
2265+
KubernetesDriverError::Precondition("sandbox namespace missing".into())
2266+
})?;
2267+
let uid = object
2268+
.metadata
2269+
.uid
2270+
.as_deref()
2271+
.ok_or_else(|| KubernetesDriverError::Precondition("sandbox UID missing".into()))?;
2272+
let placement = supervisor_placement(&object)?;
2273+
let mut pool_route = String::new();
2274+
let mut ownership_epoch = 0;
2275+
let mut member_address = None;
2276+
let pods = if let SupervisorPlacement::Shared { route } = &placement {
2277+
let Some(shared) = &self.shared_supervisor else {
2278+
return Ok(None);
2279+
};
2280+
let controller = shared.supervision().controller();
2281+
let Some(view) = controller.pool_view() else {
2282+
return Ok(None);
2283+
};
2284+
let Some(owner) = view
2285+
.owners
2286+
.get(route)
2287+
.filter(|_| view.desired.contains(route))
2288+
else {
2289+
return Ok(None);
2290+
};
2291+
let Some(member) = view.members.iter().find(|member| {
2292+
member.member_id == owner.member_id
2293+
&& member.process_id == owner.process_id
2294+
&& !member.leaving
2295+
}) else {
2296+
return Ok(None);
2297+
};
2298+
if !matches!(controller.observed(route),
2299+
openshell_supervisor_control::ObservedInstance::Reported(status)
2300+
if status.sandbox_id == sandbox_id && status.ready
2301+
&& status.phase == openshell_supervisor_control::proto::SandboxInstancePhase::Supervising as i32)
2302+
{
2303+
return Ok(None);
2304+
}
2305+
let address = member
2306+
.runtime_address
2307+
.parse::<std::net::SocketAddr>()
2308+
.map_err(|_| {
2309+
KubernetesDriverError::Precondition(
2310+
"pool member runtime address is not an IP endpoint".into(),
2311+
)
2312+
})?;
2313+
let pod =
2314+
Api::<Pod>::namespaced(self.client.clone(), &shared.config().service_namespace)
2315+
.get_opt(&owner.member_id)
2316+
.await
2317+
.map_err(KubernetesDriverError::from_kube)?;
2318+
let Some(pod) = pod else {
2319+
return Ok(None);
2320+
};
2321+
let service =
2322+
Api::<Service>::namespaced(self.client.clone(), &shared.config().service_namespace)
2323+
.get(&shared.config().service_name)
2324+
.await
2325+
.map_err(KubernetesDriverError::from_kube)?;
2326+
let Some(selector) = service
2327+
.spec
2328+
.as_ref()
2329+
.and_then(|spec| spec.selector.as_ref())
2330+
.filter(|selector| !selector.is_empty())
2331+
else {
2332+
return Ok(None);
2333+
};
2334+
if !selector.iter().all(|(key, value)| {
2335+
pod.metadata
2336+
.labels
2337+
.as_ref()
2338+
.and_then(|labels| labels.get(key))
2339+
== Some(value)
2340+
}) {
2341+
return Ok(None);
2342+
}
2343+
pool_route = route.clone();
2344+
ownership_epoch = owner.auth_epoch;
2345+
member_address = Some(address.ip());
2346+
vec![pod]
2347+
} else {
2348+
let selector =
2349+
format!("openshell.ai/sandbox-id={sandbox_id},openshell.ai/component=supervisor");
2350+
Api::<Pod>::namespaced(self.client.clone(), namespace)
2351+
.list(&ListParams::default().labels(&selector))
2352+
.await
2353+
.map_err(KubernetesDriverError::from_kube)?
2354+
.items
2355+
};
2356+
// Includes terminating members in the ambiguity check; never guess a
2357+
// replacement's ownership during a rollout.
2358+
let [pod]: [Pod; 1] = match pods.try_into() {
2359+
Ok(pods) => pods,
2360+
Err(_) => return Ok(None),
2361+
};
2362+
if pod.metadata.deletion_timestamp.is_some() {
2363+
return Ok(None);
2364+
}
2365+
let app_port = i32::from(self.config.sandbox_runtime.app_listener.common.port);
2366+
if !pod
2367+
.spec
2368+
.as_ref()
2369+
.and_then(|spec| {
2370+
spec.containers
2371+
.iter()
2372+
.find(|container| container.name == "supervisor")
2373+
})
2374+
.is_some_and(|container| {
2375+
container.ports.as_ref().is_some_and(|ports| {
2376+
ports.iter().any(|port| {
2377+
port.name.as_deref() == Some("app") && port.container_port == app_port
2378+
})
2379+
}) && container.env.as_ref().is_some_and(|env| {
2380+
env.iter().any(|entry| {
2381+
entry.name == "OPENSHELL_APP_LISTENER_ENABLED"
2382+
&& entry.value.as_deref() == Some("true")
2383+
})
2384+
})
2385+
})
2386+
{
2387+
return Ok(None);
2388+
}
2389+
let Some(pod_uid) = pod.metadata.uid.as_deref() else {
2390+
return Ok(None);
2391+
};
2392+
if matches!(placement, SupervisorPlacement::Dedicated)
2393+
&& !pod
2394+
.metadata
2395+
.owner_references
2396+
.as_ref()
2397+
.is_some_and(|owners| owners.iter().any(|o| o.uid == uid && o.kind == "Sandbox"))
2398+
{
2399+
return Ok(None);
2400+
}
2401+
let Some(status) = pod.status else {
2402+
return Ok(None);
2403+
};
2404+
if status.phase.as_deref() != Some("Running") {
2405+
return Ok(None);
2406+
}
2407+
let Some(host) = status
2408+
.pod_ip
2409+
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok())
2410+
else {
2411+
return Ok(None);
2412+
};
2413+
if member_address
2414+
.is_some_and(|address| host.parse::<std::net::IpAddr>().ok() != Some(address))
2415+
{
2416+
return Ok(None);
2417+
}
2418+
let component = match placement {
2419+
SupervisorPlacement::Dedicated => pod_uid.to_string(),
2420+
SupervisorPlacement::Shared { route } => route,
2421+
};
2422+
Ok(Some(
2423+
openshell_core::proto::compute::v1::SupervisorAppEndpoint {
2424+
host,
2425+
port: u32::from(self.config.sandbox_runtime.app_listener.common.port),
2426+
runtime_identity: kubernetes_runtime_identity(namespace, uid, &component),
2427+
pool_route,
2428+
ownership_epoch,
2429+
},
2430+
))
2431+
}
2432+
22382433
pub async fn get_sandbox(&self, sandbox_id: &str) -> Result<Option<Sandbox>, String> {
22392434
info!(
22402435
sandbox_id = %sandbox_id,

‎crates/openshell-driver-kubernetes/src/grpc.rs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,37 @@ impl ComputeDriver for ComputeDriverService {
107107
.await
108108
}
109109

110+
async fn resolve_supervisor_app_endpoint(
111+
&self,
112+
request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
113+
) -> Result<
114+
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
115+
Status,
116+
> {
117+
self.rpc_tracer
118+
.trace(
119+
openshell_otel::rpc::RESOLVE_SUPERVISOR_APP_ENDPOINT,
120+
async {
121+
let request = request.into_inner();
122+
if request.sandbox_id.is_empty() {
123+
return Err(Status::invalid_argument("sandbox_id is required"));
124+
}
125+
let endpoint = self
126+
.driver
127+
.supervisor_app_endpoint(&request.sandbox_id)
128+
.await
129+
.map_err(|e| Status::unavailable(e.to_string()))?;
130+
Ok(Response::new(
131+
openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse {
132+
sandbox_id: request.sandbox_id,
133+
supervisor_app_endpoint: endpoint,
134+
},
135+
))
136+
},
137+
)
138+
.await
139+
}
140+
110141
async fn get_sandbox(
111142
&self,
112143
request: Request<GetSandboxRequest>,

‎crates/openshell-driver-mxc/src/grpc.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,18 @@ impl ComputeDriver for ComputeDriverService {
7070
Ok(Response::new(ValidateSandboxCreateResponse {}))
7171
}
7272

73+
async fn resolve_supervisor_app_endpoint(
74+
&self,
75+
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
76+
) -> Result<
77+
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
78+
Status,
79+
> {
80+
Err(Status::unimplemented(
81+
"supervisor application endpoint resolution is not supported",
82+
))
83+
}
84+
7385
async fn get_sandbox(
7486
&self,
7587
request: Request<GetSandboxRequest>,

‎crates/openshell-driver-podman/src/grpc.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ impl ComputeDriver for ComputeDriverService {
102102
.await
103103
}
104104

105+
async fn resolve_supervisor_app_endpoint(
106+
&self,
107+
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
108+
) -> Result<
109+
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
110+
Status,
111+
> {
112+
Err(Status::unimplemented(
113+
"supervisor application endpoint resolution is not supported",
114+
))
115+
}
116+
105117
async fn get_sandbox(
106118
&self,
107119
request: Request<GetSandboxRequest>,

‎crates/openshell-driver-vm/src/driver.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5374,6 +5374,18 @@ impl ComputeDriver for VmDriver {
53745374
Ok(Response::new(response))
53755375
}
53765376

5377+
async fn resolve_supervisor_app_endpoint(
5378+
&self,
5379+
_request: Request<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointRequest>,
5380+
) -> Result<
5381+
Response<openshell_core::proto::compute::v1::ResolveSupervisorAppEndpointResponse>,
5382+
Status,
5383+
> {
5384+
Err(Status::unimplemented(
5385+
"supervisor application endpoint resolution is not supported",
5386+
))
5387+
}
5388+
53775389
async fn get_sandbox(
53785390
&self,
53795391
request: Request<GetSandboxRequest>,

‎crates/openshell-otel/src/grpc.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ pub mod rpc {
106106
"GetSandbox",
107107
"openshell.compute.v1.ComputeDriver/GetSandbox",
108108
);
109+
pub const RESOLVE_SUPERVISOR_APP_ENDPOINT: ComputeDriverRpc = ComputeDriverRpc::new(
110+
"ResolveSupervisorAppEndpoint",
111+
"openshell.compute.v1.ComputeDriver/ResolveSupervisorAppEndpoint",
112+
);
109113
pub const LIST_SANDBOXES: ComputeDriverRpc = ComputeDriverRpc::new(
110114
"ListSandboxes",
111115
"openshell.compute.v1.ComputeDriver/ListSandboxes",
@@ -195,6 +199,7 @@ pub fn compute_driver_rpc_operation(path: &str) -> Option<ComputeDriverRpc> {
195199
Some("ValidateSandboxCreate") => Some(rpc::VALIDATE_SANDBOX_CREATE),
196200
Some("CreateSandbox") => Some(rpc::CREATE_SANDBOX),
197201
Some("GetSandbox") => Some(rpc::GET_SANDBOX),
202+
Some("ResolveSupervisorAppEndpoint") => Some(rpc::RESOLVE_SUPERVISOR_APP_ENDPOINT),
198203
Some("ListSandboxes") => Some(rpc::LIST_SANDBOXES),
199204
Some("StopSandbox") => Some(rpc::STOP_SANDBOX),
200205
Some("StartSandbox") => Some(rpc::START_SANDBOX),
@@ -318,6 +323,7 @@ mod tests {
318323
rpc::VALIDATE_SANDBOX_CREATE,
319324
rpc::CREATE_SANDBOX,
320325
rpc::GET_SANDBOX,
326+
rpc::RESOLVE_SUPERVISOR_APP_ENDPOINT,
321327
rpc::LIST_SANDBOXES,
322328
rpc::STOP_SANDBOX,
323329
rpc::START_SANDBOX,

0 commit comments

Comments
 (0)