@@ -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,
0 commit comments