@@ -2026,6 +2026,201 @@ impl KubernetesComputeDriver {
20262026 Ok(())
20272027 }
20282028
2029+ /// Resolve only a unique, live driver-owned supervisor. Multi-member pool
2030+ /// resolution must use the HA assignment/member API rather than guess.
2031+ pub async fn supervisor_app_endpoint(
2032+ &self,
2033+ sandbox_id: &str,
2034+ ) -> Result<
2035+ Option<openshell_core::proto::compute::v1::SupervisorAppEndpoint>,
2036+ KubernetesDriverError,
2037+ > {
2038+ if !self.config.sandbox_runtime.app_listener.common.enabled {
2039+ return Ok(None);
2040+ }
2041+ let api = self
2042+ .supported_sandbox_api_for_lookup(self.client.clone())
2043+ .await
2044+ .map_err(KubernetesDriverError::Message)?;
2045+ let objects = api
2046+ .api
2047+ .list(&ListParams::default().labels(&self.sandbox_lookup_selector(sandbox_id)))
2048+ .await
2049+ .map_err(KubernetesDriverError::from_kube)?
2050+ .items;
2051+ let [object]: [DynamicObject; 1] = match objects.try_into() {
2052+ Ok(objects) => objects,
2053+ Err(_) => return Ok(None),
2054+ };
2055+ let namespace = object.metadata.namespace.as_deref().ok_or_else(|| {
2056+ KubernetesDriverError::Precondition("sandbox namespace missing".into())
2057+ })?;
2058+ let uid = object
2059+ .metadata
2060+ .uid
2061+ .as_deref()
2062+ .ok_or_else(|| KubernetesDriverError::Precondition("sandbox UID missing".into()))?;
2063+ let placement = supervisor_placement(&object)?;
2064+ let mut pool_route = String::new();
2065+ let mut ownership_epoch = 0;
2066+ let mut member_address = None;
2067+ let pods = if let SupervisorPlacement::Shared { route } = &placement {
2068+ let Some(shared) = &self.shared_supervisor else {
2069+ return Ok(None);
2070+ };
2071+ let controller = shared.supervision().controller();
2072+ let Some(view) = controller.pool_view() else {
2073+ return Ok(None);
2074+ };
2075+ let Some(owner) = view
2076+ .owners
2077+ .get(route)
2078+ .filter(|_| view.desired.contains(route))
2079+ else {
2080+ return Ok(None);
2081+ };
2082+ let Some(member) = view.members.iter().find(|member| {
2083+ member.member_id == owner.member_id
2084+ && member.process_id == owner.process_id
2085+ && !member.leaving
2086+ }) else {
2087+ return Ok(None);
2088+ };
2089+ if !matches!(controller.observed(route),
2090+ openshell_supervisor_control::ObservedInstance::Reported(status)
2091+ if status.sandbox_id == sandbox_id && status.ready
2092+ && status.phase == openshell_supervisor_control::proto::SandboxInstancePhase::Supervising as i32)
2093+ {
2094+ return Ok(None);
2095+ }
2096+ let address = member
2097+ .runtime_address
2098+ .parse::<std::net::SocketAddr>()
2099+ .map_err(|_| {
2100+ KubernetesDriverError::Precondition(
2101+ "pool member runtime address is not an IP endpoint".into(),
2102+ )
2103+ })?;
2104+ let pod =
2105+ Api::<Pod>::namespaced(self.client.clone(), &shared.config().service_namespace)
2106+ .get_opt(&owner.member_id)
2107+ .await
2108+ .map_err(KubernetesDriverError::from_kube)?;
2109+ let Some(pod) = pod else {
2110+ return Ok(None);
2111+ };
2112+ let service =
2113+ Api::<Service>::namespaced(self.client.clone(), &shared.config().service_namespace)
2114+ .get(&shared.config().service_name)
2115+ .await
2116+ .map_err(KubernetesDriverError::from_kube)?;
2117+ let Some(selector) = service
2118+ .spec
2119+ .as_ref()
2120+ .and_then(|spec| spec.selector.as_ref())
2121+ .filter(|selector| !selector.is_empty())
2122+ else {
2123+ return Ok(None);
2124+ };
2125+ if !selector.iter().all(|(key, value)| {
2126+ pod.metadata
2127+ .labels
2128+ .as_ref()
2129+ .and_then(|labels| labels.get(key))
2130+ == Some(value)
2131+ }) {
2132+ return Ok(None);
2133+ }
2134+ pool_route = route.clone();
2135+ ownership_epoch = owner.auth_epoch;
2136+ member_address = Some(address.ip());
2137+ vec![pod]
2138+ } else {
2139+ let selector =
2140+ format!("openshell.ai/sandbox-id={sandbox_id},openshell.ai/component=supervisor");
2141+ Api::<Pod>::namespaced(self.client.clone(), namespace)
2142+ .list(&ListParams::default().labels(&selector))
2143+ .await
2144+ .map_err(KubernetesDriverError::from_kube)?
2145+ .items
2146+ };
2147+ // Includes terminating members in the ambiguity check; never guess a
2148+ // replacement's ownership during a rollout.
2149+ let [pod]: [Pod; 1] = match pods.try_into() {
2150+ Ok(pods) => pods,
2151+ Err(_) => return Ok(None),
2152+ };
2153+ if pod.metadata.deletion_timestamp.is_some() {
2154+ return Ok(None);
2155+ }
2156+ let app_port = i32::from(self.config.sandbox_runtime.app_listener.common.port);
2157+ if !pod
2158+ .spec
2159+ .as_ref()
2160+ .and_then(|spec| {
2161+ spec.containers
2162+ .iter()
2163+ .find(|container| container.name == "supervisor")
2164+ })
2165+ .is_some_and(|container| {
2166+ container.ports.as_ref().is_some_and(|ports| {
2167+ ports.iter().any(|port| {
2168+ port.name.as_deref() == Some("app") && port.container_port == app_port
2169+ })
2170+ }) && container.env.as_ref().is_some_and(|env| {
2171+ env.iter().any(|entry| {
2172+ entry.name == "OPENSHELL_APP_LISTENER_ENABLED"
2173+ && entry.value.as_deref() == Some("true")
2174+ })
2175+ })
2176+ })
2177+ {
2178+ return Ok(None);
2179+ }
2180+ let Some(pod_uid) = pod.metadata.uid.as_deref() else {
2181+ return Ok(None);
2182+ };
2183+ if matches!(placement, SupervisorPlacement::Dedicated)
2184+ && !pod
2185+ .metadata
2186+ .owner_references
2187+ .as_ref()
2188+ .is_some_and(|owners| owners.iter().any(|o| o.uid == uid && o.kind == "Sandbox"))
2189+ {
2190+ return Ok(None);
2191+ }
2192+ let Some(status) = pod.status else {
2193+ return Ok(None);
2194+ };
2195+ if status.phase.as_deref() != Some("Running") {
2196+ return Ok(None);
2197+ }
2198+ let Some(host) = status
2199+ .pod_ip
2200+ .filter(|ip| ip.parse::<std::net::IpAddr>().is_ok())
2201+ else {
2202+ return Ok(None);
2203+ };
2204+ if member_address
2205+ .is_some_and(|address| host.parse::<std::net::IpAddr>().ok() != Some(address))
2206+ {
2207+ return Ok(None);
2208+ }
2209+ let component = match placement {
2210+ SupervisorPlacement::Dedicated => pod_uid.to_string(),
2211+ SupervisorPlacement::Shared { route } => route,
2212+ };
2213+ Ok(Some(
2214+ openshell_core::proto::compute::v1::SupervisorAppEndpoint {
2215+ host,
2216+ port: u32::from(self.config.sandbox_runtime.app_listener.common.port),
2217+ runtime_identity: kubernetes_runtime_identity(namespace, uid, &component),
2218+ pool_route,
2219+ ownership_epoch,
2220+ },
2221+ ))
2222+ }
2223+
20292224 pub async fn get_sandbox(&self, sandbox_id: &str) -> Result<Option<Sandbox>, String> {
20302225 info!(
20312226 sandbox_id = %sandbox_id,
0 commit comments