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