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