1use std::collections::HashMap;
365use std::collections::HashSet;
366use std::io;
367use std::path::Path;
368use std::sync::Arc;
369use std::time::Duration;
370
371use async_trait::async_trait;
372use axum::Json;
373use axum::Router;
374use axum::extract::Path as AxumPath;
375use axum::extract::State;
376use axum::http::StatusCode;
377use axum::response::IntoResponse;
378use axum::routing::get;
379use axum::routing::post;
380use futures::StreamExt;
381use hyperactor::Actor;
382use hyperactor::ActorHandle;
383use hyperactor::ActorRef;
384use hyperactor::Context;
385use hyperactor::Endpoint as _;
386use hyperactor::HandleClient;
387use hyperactor::Handler;
388use hyperactor::Instance;
389use hyperactor::OncePortRef;
390use hyperactor::ProcAddr;
391use hyperactor::RefClient;
392use hyperactor::channel::ChannelAddr;
393use hyperactor::channel::try_tls_acceptor_with_pem_bundle;
394use hyperactor::config::Pem;
395use hyperactor::config::PemBundle;
396use hyperactor::introspect::IntrospectMessage;
397use hyperactor::introspect::IntrospectResult;
398use hyperactor::introspect::IntrospectView;
399use hyperactor::mailbox::open_once_port;
400use serde::Deserialize;
401use serde::Serialize;
402use serde_json::Value;
403use tokio::net::TcpListener;
404use tokio_rustls::TlsAcceptor;
405use typeuri::Named;
406
407use crate::config_dump::ConfigDump;
408use crate::config_dump::ConfigDumpResult;
409use crate::host_mesh::HostAgentKey;
410use crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME;
411use crate::host_mesh::host_agent::HostAgent;
412use crate::introspect::NodePayload;
413use crate::introspect::NodeProperties;
414use crate::introspect::dto::NodePayloadDto;
415use crate::introspect::to_node_payload;
416use crate::proc_agent::PROC_AGENT_ACTOR_NAME;
417use crate::proc_agent::ProcAgent;
418use crate::pyspy::PySpyDump;
419use crate::pyspy::PySpyOpts;
420use crate::pyspy::PySpyProfile;
421use crate::pyspy::PySpyProfileOpts;
422use crate::pyspy::PySpyProfileResult;
423use crate::pyspy::PySpyResult;
424use crate::pyspy::ValidatedProfileRequest;
425
426fn mesh_admin_access_instructions(admin_url: &str, tls_bundle: Option<&PemBundle>) -> String {
428 fn path(pem: &Pem) -> Option<&Path> {
429 match pem {
430 Pem::File(path) => Some(path),
431 Pem::StaticPath(path) => Some(Path::new(*path)),
432 Pem::Value(_) => None,
433 }
434 }
435
436 let curl_prefix = if admin_url.starts_with("https://") {
437 tls_bundle.and_then(|bundle| {
438 Some(format!(
439 "curl --cacert {} --cert {} --key {} ",
440 path(&bundle.ca)?.display(),
441 path(&bundle.cert)?.display(),
442 path(&bundle.key)?.display(),
443 ))
444 })
445 } else {
446 Some("curl ".to_string())
447 };
448 let access = |path: &str| match &curl_prefix {
449 Some(prefix) => format!("{prefix}{admin_url}{path}"),
450 None => format!("{admin_url}{path}"),
451 };
452
453 format!(
454 concat!(
455 "Mesh admin server listening on {admin_url}\n",
456 " - Root node: {root_access}\n",
457 " - Mesh tree: {tree_access}\n",
458 " - API docs: {docs_access}\n",
459 " - TUI: buck2 run ",
460 "fbcode//monarch/hyperactor_mesh_admin_tui:hyperactor_mesh_admin_tui ",
461 "-- --addr {admin_url}\n",
462 " cargo run -p hyperactor_mesh_admin_tui_lib ",
463 "--bin hyperactor_mesh_admin_tui -- --addr {admin_url}",
464 ),
465 admin_url = admin_url,
466 root_access = access("/v1/root"),
467 tree_access = access("/v1/tree"),
468 docs_access = access("/SKILL.md"),
469 )
470}
471
472async fn query_introspect(
475 cx: &hyperactor::Context<'_, MeshAdminAgent>,
476 actor_id: &hyperactor::ActorAddr,
477 view: hyperactor::introspect::IntrospectView,
478 timeout: Duration,
479 err_ctx: &str,
480) -> Result<IntrospectResult, anyhow::Error> {
481 let introspect_port = actor_id.introspect_port();
482 let (reply_handle, reply_rx) = open_once_port::<IntrospectResult>(cx);
483 let mut reply_ref = reply_handle.bind();
484 reply_ref.return_undeliverable(false);
485 introspect_port.post(
486 cx,
487 IntrospectMessage::Query {
488 view,
489 reply: reply_ref,
490 },
491 );
492 tokio::time::timeout(timeout, reply_rx.recv())
493 .await
494 .map_err(|_| anyhow::anyhow!("timed out {}", err_ctx))?
495 .map_err(|e| anyhow::anyhow!("failed to receive {}: {}", err_ctx, e))
496}
497
498fn host_snapshot(payload: &NodePayload) -> Result<HashSet<ProcAddr>, anyhow::Error> {
501 anyhow::ensure!(
502 matches!(payload.properties, NodeProperties::Host { .. }),
503 "{} did not answer as a host",
504 payload.identity
505 );
506 Ok(payload
507 .children
508 .iter()
509 .filter_map(|child| match child {
510 crate::introspect::NodeRef::Proc(proc) => Some(proc.clone()),
511 _ => None,
512 })
513 .collect())
514}
515
516async fn query_child_introspect(
518 cx: &hyperactor::Context<'_, MeshAdminAgent>,
519 actor_id: &hyperactor::ActorAddr,
520 child_ref: hyperactor::Addr,
521 timeout: Duration,
522 err_ctx: &str,
523) -> Result<IntrospectResult, anyhow::Error> {
524 let introspect_port = actor_id.introspect_port();
525 let (reply_handle, reply_rx) = open_once_port::<IntrospectResult>(cx);
526 let mut reply_ref = reply_handle.bind();
527 reply_ref.return_undeliverable(false);
528 introspect_port.post(
529 cx,
530 IntrospectMessage::QueryChild {
531 child_ref,
532 reply: reply_ref,
533 },
534 );
535 tokio::time::timeout(timeout, reply_rx.recv())
536 .await
537 .map_err(|_| anyhow::anyhow!("timed out {}", err_ctx))?
538 .map_err(|e| anyhow::anyhow!("failed to receive {}: {}", err_ctx, e))
539}
540
541pub const MESH_ADMIN_ACTOR_NAME: &str = "mesh_admin";
543
544pub const MESH_ADMIN_BRIDGE_NAME: &str = "mesh_admin_bridge";
561
562#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
565pub struct ApiError {
566 pub code: String,
568 pub message: String,
570 #[serde(skip_serializing_if = "Option::is_none")]
574 pub details: Option<Value>,
575}
576
577#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
579pub struct ApiErrorEnvelope {
580 pub error: ApiError,
581}
582
583impl ApiError {
584 pub fn not_found(message: impl Into<String>, details: Option<Value>) -> Self {
586 Self {
587 code: "not_found".to_string(),
588 message: message.into(),
589 details,
590 }
591 }
592
593 pub fn bad_request(message: impl Into<String>, details: Option<Value>) -> Self {
595 Self {
596 code: "bad_request".to_string(),
597 message: message.into(),
598 details,
599 }
600 }
601}
602
603impl IntoResponse for ApiError {
604 fn into_response(self) -> axum::response::Response {
605 let status = match self.code.as_str() {
606 "not_found" => StatusCode::NOT_FOUND,
607 "bad_request" => StatusCode::BAD_REQUEST,
608 "gateway_timeout" => StatusCode::GATEWAY_TIMEOUT,
609 "service_unavailable" => StatusCode::SERVICE_UNAVAILABLE,
610 _ => StatusCode::INTERNAL_SERVER_ERROR,
611 };
612 let envelope = ApiErrorEnvelope { error: self };
613 (status, Json(envelope)).into_response()
614 }
615}
616
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
622pub struct MeshAdminAddrResponse {
623 pub addr: Option<String>,
624}
625wirevalue::register_type!(MeshAdminAddrResponse);
626
627#[derive(
633 Debug,
634 Clone,
635 PartialEq,
636 Serialize,
637 Deserialize,
638 Handler,
639 HandleClient,
640 RefClient,
641 Named
642)]
643pub enum MeshAdminMessage {
644 GetAdminAddr {
649 #[reply]
650 reply: OncePortRef<MeshAdminAddrResponse>,
651 },
652}
653wirevalue::register_type!(MeshAdminMessage);
654
655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
658pub struct ResolveReferenceResponse(pub Result<NodePayload, String>);
659wirevalue::register_type!(ResolveReferenceResponse);
660
661#[derive(
678 Debug,
679 Clone,
680 PartialEq,
681 Serialize,
682 Deserialize,
683 Handler,
684 HandleClient,
685 RefClient,
686 Named
687)]
688pub enum ResolveReferenceMessage {
689 Resolve {
694 reference_string: String,
697 #[reply]
699 reply: OncePortRef<ResolveReferenceResponse>,
700 },
701}
702wirevalue::register_type!(ResolveReferenceMessage);
703
704#[hyperactor::export(handlers = [MeshAdminMessage, ResolveReferenceMessage])]
717pub struct MeshAdminAgent {
718 hosts: Vec<ActorRef<HostAgent>>,
721
722 hosts_by_identity: HashMap<HostAgentKey, ActorRef<HostAgent>>,
731
732 host_candidates_by_addr: HashMap<ChannelAddr, Vec<HostAgentKey>>,
738
739 proc_owners: HashMap<ProcAddr, HostAgentKey>,
743
744 root_client_actor_id: Option<hyperactor::ActorAddr>,
749
750 self_actor_id: Option<hyperactor::ActorAddr>,
755
756 admin_addr_override: Option<std::net::SocketAddr>,
773
774 admin_addr: Option<std::net::SocketAddr>,
777
778 admin_host: Option<String>,
781
782 telemetry_url: Option<String>,
786
787 started_at: String,
789
790 started_by: String,
792}
793
794impl MeshAdminAgent {
795 pub fn new(
811 hosts: Vec<(String, ActorRef<HostAgent>)>,
812 root_client_actor_id: Option<hyperactor::ActorAddr>,
813 admin_addr: Option<std::net::SocketAddr>,
814 telemetry_url: Option<String>,
815 ) -> Self {
816 let mut hosts_by_identity = HashMap::new();
817 let mut host_candidates_by_addr: HashMap<ChannelAddr, Vec<HostAgentKey>> = HashMap::new();
818 let mut ordered = Vec::with_capacity(hosts.len());
819 for (_, agent_ref) in hosts {
820 let key = HostAgentKey::new(agent_ref.actor_addr());
821 assert!(
822 hosts_by_identity
823 .insert(key.clone(), agent_ref.clone())
824 .is_none(),
825 "host agent {} is listed more than once",
826 agent_ref.actor_addr()
827 );
828 host_candidates_by_addr
829 .entry(agent_ref.actor_addr().addr().clone())
830 .or_default()
831 .push(key);
832 ordered.push(agent_ref);
833 }
834
835 let started_at = chrono::Utc::now().to_rfc3339();
837 let started_by = std::env::var("USER")
838 .or_else(|_| std::env::var("USERNAME"))
839 .unwrap_or_else(|_| "unknown".to_string());
840
841 Self {
842 hosts: ordered,
843 hosts_by_identity,
844 host_candidates_by_addr,
845 proc_owners: HashMap::new(),
846 root_client_actor_id,
847 self_actor_id: None,
848 admin_addr_override: admin_addr,
849 admin_addr: None,
850 admin_host: None,
851 telemetry_url,
852 started_at,
853 started_by,
854 }
855 }
856}
857
858impl std::fmt::Debug for MeshAdminAgent {
859 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860 f.debug_struct("MeshAdminAgent")
861 .field("hosts", &self.hosts)
862 .field("proc_owners", &self.proc_owners.len())
863 .field("root_client_actor_id", &self.root_client_actor_id)
864 .field("self_actor_id", &self.self_actor_id)
865 .field("admin_addr", &self.admin_addr)
866 .field("admin_host", &self.admin_host)
867 .field("started_at", &self.started_at)
868 .field("started_by", &self.started_by)
869 .finish()
870 }
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
880pub struct AdminInfo {
881 pub actor_id: String,
883 pub proc_id: String,
885 pub host: String,
887 pub url: String,
889}
890
891impl AdminInfo {
892 pub fn new(actor_id: String, proc_id: String, url: String) -> anyhow::Result<Self> {
899 let parsed = url::Url::parse(&url)
900 .map_err(|e| anyhow::anyhow!("invalid admin URL '{}': {}", url, e))?;
901 let host = parsed
902 .host_str()
903 .ok_or_else(|| anyhow::anyhow!("admin URL '{}' has no host", url))?
904 .to_string();
905 Ok(Self {
906 actor_id,
907 proc_id,
908 host,
909 url,
910 })
911 }
912}
913
914struct BridgeState {
923 admin_ref: ActorRef<MeshAdminAgent>,
926 host_agents: HashSet<HostAgentKey>,
928 bridge_cx: Instance<()>,
936 resolve_semaphore: tokio::sync::Semaphore,
940 _bridge_handle: ActorHandle<()>,
942 telemetry_url: Option<String>,
947 http_client: reqwest::Client,
950 admin_info: AdminInfo,
952}
953
954fn build_http_client() -> reqwest::Client {
961 use std::io::Read;
962
963 if let Some(bundle) = hyperactor::channel::try_tls_pem_bundle() {
964 let mut ca_bytes = Vec::new();
965 if let Ok(mut reader) = bundle.ca.reader()
966 && reader.read_to_end(&mut ca_bytes).is_ok()
967 {
968 let (builder, ca_installed) = crate::mesh_admin_client::add_tls(
969 reqwest::Client::builder(),
970 &ca_bytes,
971 None,
972 None,
973 );
974 if ca_installed {
975 if let Ok(client) = builder.build() {
976 return client;
977 }
978 tracing::warn!(
979 "mesh admin: failed to build reqwest client with root CA; \
980 falling back to default trust store"
981 );
982 }
983 }
984 }
985 reqwest::Client::new()
986}
987
988struct TlsListener {
995 tcp: TcpListener,
996 acceptor: TlsAcceptor,
997}
998
999impl axum::serve::Listener for TlsListener {
1000 type Io = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
1001 type Addr = std::net::SocketAddr;
1002
1003 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
1004 loop {
1005 let (stream, addr) = match self.tcp.accept().await {
1006 Ok(conn) => conn,
1007 Err(e) => {
1008 tracing::warn!("TCP accept error: {}", e);
1009 continue;
1010 }
1011 };
1012
1013 match self.acceptor.accept(stream).await {
1014 Ok(tls_stream) => return (tls_stream, addr),
1015 Err(e) => {
1016 tracing::warn!("TLS handshake failed from {}: {}", addr, e);
1017 continue;
1018 }
1019 }
1020 }
1021 }
1022
1023 fn local_addr(&self) -> io::Result<Self::Addr> {
1024 self.tcp.local_addr()
1025 }
1026}
1027
1028#[async_trait]
1029impl Actor for MeshAdminAgent {
1030 async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
1052 this.bind::<Self>();
1056 this.set_system();
1057 self.self_actor_id = Some(this.self_addr().clone());
1058
1059 let bind_addr = match self.admin_addr_override {
1060 Some(addr) => addr,
1061 None => hyperactor_config::global::get_cloned(crate::config::MESH_ADMIN_ADDR)
1062 .parse_socket_addr()
1063 .map_err(|e| anyhow::anyhow!("invalid MESH_ADMIN_ADDR config: {}", e))?,
1064 };
1065 let listener = TcpListener::bind(bind_addr).await?;
1066 let bound_addr = listener.local_addr()?;
1067 self.admin_addr = Some(bound_addr);
1068
1069 let enforce_mtls = cfg!(fbcode_build);
1073 let tls = try_tls_acceptor_with_pem_bundle(enforce_mtls);
1074
1075 if enforce_mtls && tls.is_none() {
1076 return Err(anyhow::anyhow!(
1077 "mesh admin requires mTLS but no TLS certificates found; \
1078 set HYPERACTOR_TLS_CERT/KEY/CA or ensure Meta cert paths exist \
1079 (/var/facebook/x509_identities/server.pem, /var/facebook/rootcanal/ca.pem)"
1080 ));
1081 }
1082
1083 let scheme = if tls.is_some() { "https" } else { "http" };
1084
1085 let host = if !bound_addr.ip().is_unspecified() {
1095 let ip = bound_addr.ip();
1096 if ip.is_loopback() {
1097 "localhost".to_string()
1098 } else if let std::net::IpAddr::V6(v6) = ip {
1099 format!("[{}]", v6)
1100 } else {
1101 ip.to_string()
1102 }
1103 } else {
1104 advertised_host::from_cert_sans()
1105 };
1106 self.admin_host = Some(format!("{}://{}:{}", scheme, host, bound_addr.port()));
1107
1108 let (bridge_cx, bridge_handle) = this
1112 .proc()
1113 .introspectable_instance(MESH_ADMIN_BRIDGE_NAME)?;
1114 bridge_cx.set_system();
1115 let admin_url = self
1116 .admin_host
1117 .clone()
1118 .unwrap_or_else(|| "unknown".to_string());
1119 let access_instructions =
1120 mesh_admin_access_instructions(&admin_url, tls.as_ref().map(|(_, bundle)| bundle));
1121 let bridge_state = Arc::new(BridgeState {
1122 admin_ref: ActorRef::attest(this.self_addr().clone()),
1123 host_agents: self.hosts_by_identity.keys().cloned().collect(),
1124 bridge_cx,
1125 resolve_semaphore: tokio::sync::Semaphore::new(hyperactor_config::global::get(
1126 crate::config::MESH_ADMIN_MAX_CONCURRENT_RESOLVES,
1127 )),
1128 _bridge_handle: bridge_handle,
1129 telemetry_url: self.telemetry_url.clone(),
1130 http_client: build_http_client(),
1131 admin_info: AdminInfo::new(
1132 this.self_addr().to_string(),
1133 this.self_addr().proc_addr().to_string(),
1134 admin_url,
1135 )?,
1136 });
1137 let router = create_mesh_admin_router(bridge_state);
1138
1139 if let Some((acceptor, _)) = tls {
1140 let tls_listener = TlsListener {
1141 tcp: listener,
1142 acceptor,
1143 };
1144 tokio::spawn(async move {
1145 if let Err(e) = axum::serve(tls_listener, router).await {
1146 tracing::error!("mesh admin server (mTLS) error: {}", e);
1147 }
1148 });
1149 } else {
1150 tokio::spawn(async move {
1152 if let Err(e) = axum::serve(listener, router).await {
1153 tracing::error!("mesh admin server error: {}", e);
1154 }
1155 });
1156 }
1157
1158 println!("{access_instructions}");
1159 tracing::info!(
1160 "mesh admin server listening on {}",
1161 self.admin_host.as_deref().unwrap_or("unknown")
1162 );
1163 Ok(())
1164 }
1165
1166 async fn handle_undeliverable_message(
1180 &mut self,
1181 _cx: &Instance<Self>,
1182 _reason: hyperactor::mailbox::UndeliverableReason,
1183 undeliverable: hyperactor::mailbox::Undeliverable<hyperactor::mailbox::MessageEnvelope>,
1184 ) -> Result<(), anyhow::Error> {
1185 match undeliverable {
1186 hyperactor::mailbox::Undeliverable::Returned(envelope) => {
1187 tracing::debug!(
1188 "admin agent: undeliverable message to {} (port not bound?), ignoring",
1189 envelope.dest(),
1190 );
1191 }
1192 hyperactor::mailbox::Undeliverable::Report(report) => {
1193 tracing::debug!(
1194 "admin agent: undeliverable message report to {} ({}), ignoring",
1195 report.dest,
1196 report.error_msg().unwrap_or_default(),
1197 );
1198 }
1199 }
1200 Ok(())
1201 }
1202
1203 async fn handle_invalid_reference(
1204 &mut self,
1205 _cx: &Instance<Self>,
1206 invalid: hyperactor::mailbox::InvalidReference,
1207 undeliverable: hyperactor::mailbox::Undeliverable<hyperactor::mailbox::MessageEnvelope>,
1208 ) -> Result<(), anyhow::Error> {
1209 tracing::debug!(
1210 %invalid,
1211 "admin agent: invalid reference from introspection probe, ignoring",
1212 );
1213 match undeliverable {
1214 hyperactor::mailbox::Undeliverable::Returned(envelope) => {
1215 tracing::debug!(
1216 "admin agent: undeliverable message to {} (invalid reference), ignoring",
1217 envelope.dest(),
1218 );
1219 }
1220 hyperactor::mailbox::Undeliverable::Report(report) => {
1221 tracing::debug!(
1222 "admin agent: undeliverable message report to {} ({}), ignoring",
1223 report.dest,
1224 report.error_msg().unwrap_or_default(),
1225 );
1226 }
1227 }
1228 Ok(())
1229 }
1230}
1231
1232#[async_trait]
1235impl Handler<MeshAdminMessage> for MeshAdminAgent {
1236 async fn handle(
1243 &mut self,
1244 cx: &Context<Self>,
1245 msg: MeshAdminMessage,
1246 ) -> Result<(), anyhow::Error> {
1247 match msg {
1248 MeshAdminMessage::GetAdminAddr { reply } => {
1249 let resp = MeshAdminAddrResponse {
1250 addr: self.admin_host.clone(),
1251 };
1252 reply.post(cx, resp);
1253 }
1254 }
1255 Ok(())
1256 }
1257}
1258
1259#[async_trait]
1262impl Handler<ResolveReferenceMessage> for MeshAdminAgent {
1263 async fn handle(
1271 &mut self,
1272 cx: &Context<Self>,
1273 msg: ResolveReferenceMessage,
1274 ) -> Result<(), anyhow::Error> {
1275 match msg {
1276 ResolveReferenceMessage::Resolve {
1277 reference_string,
1278 reply,
1279 } => {
1280 let response = ResolveReferenceResponse(
1281 self.resolve_reference(cx, &reference_string)
1282 .await
1283 .map_err(|e| format!("{:#}", e)),
1284 );
1285 reply.post(cx, response);
1286 }
1287 }
1288 Ok(())
1289 }
1290}
1291
1292impl MeshAdminAgent {
1293 async fn resolve_reference(
1312 &mut self,
1313 cx: &Context<'_, Self>,
1314 reference_string: &str,
1315 ) -> Result<NodePayload, anyhow::Error> {
1316 let node_ref: crate::introspect::NodeRef = reference_string
1317 .parse()
1318 .map_err(|e| anyhow::anyhow!("invalid reference '{}': {}", reference_string, e))?;
1319
1320 match &node_ref {
1321 crate::introspect::NodeRef::Root => Ok(self.build_root_payload()),
1322 crate::introspect::NodeRef::Host(actor_id) => {
1323 self.resolve_host_node(cx, actor_id).await
1324 }
1325 crate::introspect::NodeRef::Proc(proc_id) => {
1326 match self.resolve_proc_node(cx, proc_id).await {
1327 Ok(payload) => Ok(payload),
1328 Err(_) if self.standalone_proc_anchor(proc_id).is_some() => {
1329 self.resolve_standalone_proc_node(cx, proc_id).await
1330 }
1331 Err(e) => Err(e),
1332 }
1333 }
1334 crate::introspect::NodeRef::Actor(actor_id) => {
1335 self.resolve_actor_node(cx, actor_id).await
1336 }
1337 }
1338 }
1339
1340 fn standalone_proc_actors(&self) -> impl Iterator<Item = &hyperactor::ActorAddr> {
1348 std::iter::empty()
1349 }
1350
1351 fn standalone_proc_anchor(&self, proc_id: &ProcAddr) -> Option<&hyperactor::ActorAddr> {
1354 self.standalone_proc_actors()
1355 .find(|actor_id| actor_id.proc_addr() == *proc_id)
1356 }
1357
1358 fn is_standalone_proc_actor(&self, actor_id: &hyperactor::ActorAddr) -> bool {
1360 self.standalone_proc_actors()
1361 .any(|a| a.proc_addr() == actor_id.proc_addr())
1362 }
1363
1364 fn build_root_payload(&self) -> NodePayload {
1370 use crate::introspect::NodeRef;
1371
1372 let children: Vec<NodeRef> = self
1373 .hosts
1374 .iter()
1375 .map(|agent| NodeRef::Host(agent.actor_addr().clone()))
1376 .collect();
1377 let system_children: Vec<NodeRef> = Vec::new(); let mut attrs = hyperactor_config::Attrs::new();
1379 attrs.set(crate::introspect::NODE_TYPE, "root".to_string());
1380 attrs.set(crate::introspect::NUM_HOSTS, self.hosts.len());
1381 if let Ok(t) = humantime::parse_rfc3339(&self.started_at) {
1382 attrs.set(crate::introspect::STARTED_AT, t);
1383 }
1384 attrs.set(crate::introspect::STARTED_BY, self.started_by.clone());
1385 attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children.clone());
1386 let attrs_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
1387 NodePayload {
1388 identity: NodeRef::Root,
1389 properties: crate::introspect::derive_properties(&attrs_json),
1390 children,
1391 parent: None,
1392 as_of: std::time::SystemTime::now(),
1393 }
1394 }
1395
1396 async fn owning_host(
1410 &mut self,
1411 cx: &Context<'_, Self>,
1412 proc_id: &ProcAddr,
1413 ) -> Result<Option<ActorRef<HostAgent>>, anyhow::Error> {
1414 if let Some(owner) = self.proc_owners.get(proc_id) {
1415 return Ok(Some(self.configured_host(owner).clone()));
1416 }
1417 let Some(candidates) = self.host_candidates_by_addr.get(proc_id.addr()) else {
1418 return Ok(None);
1419 };
1420 let candidates: Vec<(HostAgentKey, ActorRef<HostAgent>)> = candidates
1421 .iter()
1422 .map(|key| (key.clone(), self.configured_host(key).clone()))
1423 .collect();
1424 if let [(_, only)] = candidates.as_slice() {
1425 return Ok(Some(only.clone()));
1426 }
1427
1428 let timeout =
1431 hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT) / 2;
1432 let queries: Vec<_> = candidates
1433 .iter()
1434 .map(|(_, agent)| self.query_host_node(cx, agent.actor_addr(), timeout))
1435 .collect();
1436 let answers: Vec<_> = futures::stream::iter(queries)
1437 .buffered(candidates.len())
1438 .collect()
1439 .await;
1440
1441 let mut snapshots = Vec::new();
1442 let mut failure = None;
1443 for ((key, _), answer) in candidates.into_iter().zip(answers) {
1444 match answer.and_then(|payload| host_snapshot(&payload)) {
1445 Ok(procs) => snapshots.push((key, procs)),
1446 Err(e) => {
1447 failure.get_or_insert(e);
1448 }
1449 }
1450 }
1451 self.apply_host_snapshots(snapshots)?;
1452
1453 if let Some(owner) = self.proc_owners.get(proc_id) {
1454 return Ok(Some(self.configured_host(owner).clone()));
1455 }
1456 match failure {
1457 Some(e) => Err(e.context(format!(
1458 "cannot tell which host at {} owns {}",
1459 proc_id.addr(),
1460 proc_id
1461 ))),
1462 None => Ok(None),
1463 }
1464 }
1465
1466 fn configured_host(&self, key: &HostAgentKey) -> &ActorRef<HostAgent> {
1468 self.hosts_by_identity
1469 .get(key)
1470 .expect("ownership refers only to configured hosts")
1471 }
1472
1473 fn apply_host_snapshots(
1483 &mut self,
1484 snapshots: Vec<(HostAgentKey, HashSet<ProcAddr>)>,
1485 ) -> Result<(), anyhow::Error> {
1486 let mut claims: HashMap<&ProcAddr, &HostAgentKey> = HashMap::new();
1487 for (host, procs) in &snapshots {
1488 anyhow::ensure!(
1489 self.hosts_by_identity.contains_key(host),
1490 "host agent {:?} is not configured",
1491 host
1492 );
1493 for proc in procs {
1494 let other = claims
1495 .insert(proc, host)
1496 .filter(|other| *other != host)
1497 .or_else(|| self.proc_owners.get(proc).filter(|owner| *owner != host));
1498 if let Some(other) = other {
1499 anyhow::bail!(
1500 "proc {} is claimed by host agents {} and {}",
1501 proc,
1502 self.configured_host(other).actor_addr(),
1503 self.configured_host(host).actor_addr()
1504 );
1505 }
1506 }
1507 }
1508 for (host, procs) in snapshots {
1509 self.proc_owners
1510 .retain(|proc, owner| *owner != host || procs.contains(proc));
1511 self.proc_owners
1512 .extend(procs.into_iter().map(|proc| (proc, host.clone())));
1513 }
1514 Ok(())
1515 }
1516
1517 async fn resolve_host_node(
1524 &mut self,
1525 cx: &Context<'_, Self>,
1526 actor_id: &hyperactor::ActorAddr,
1527 ) -> Result<NodePayload, anyhow::Error> {
1528 let payload = self
1529 .query_host_node(
1530 cx,
1531 actor_id,
1532 hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
1533 )
1534 .await?;
1535 let key = HostAgentKey::new(actor_id);
1536 if self.hosts_by_identity.contains_key(&key)
1537 && let Ok(procs) = host_snapshot(&payload)
1538 {
1539 self.apply_host_snapshots(vec![(key, procs)])?;
1540 }
1541 Ok(payload)
1542 }
1543
1544 async fn query_host_node(
1552 &self,
1553 cx: &Context<'_, Self>,
1554 actor_id: &hyperactor::ActorAddr,
1555 timeout: Duration,
1556 ) -> Result<NodePayload, anyhow::Error> {
1557 let err_ctx = format!("querying host agent {}", actor_id);
1558 let result = query_introspect(
1559 cx,
1560 actor_id,
1561 hyperactor::introspect::IntrospectView::Entity,
1562 timeout,
1563 &err_ctx,
1564 )
1565 .await?;
1566 Ok(crate::introspect::to_node_payload_with(
1567 result,
1568 crate::introspect::NodeRef::Host(actor_id.clone()),
1569 Some(crate::introspect::NodeRef::Root),
1570 ))
1571 }
1572
1573 async fn resolve_proc_node(
1584 &mut self,
1585 cx: &Context<'_, Self>,
1586 proc_id: &ProcAddr,
1587 ) -> Result<NodePayload, anyhow::Error> {
1588 let agent = self
1589 .owning_host(cx, proc_id)
1590 .await?
1591 .ok_or_else(|| anyhow::anyhow!("no host found for proc {}", proc_id))?;
1592
1593 let result = query_child_introspect(
1595 cx,
1596 agent.actor_addr(),
1597 hyperactor::Addr::Proc(proc_id.clone()),
1598 hyperactor_config::global::get(crate::config::MESH_ADMIN_QUERY_CHILD_TIMEOUT),
1599 "querying proc details",
1600 )
1601 .await?;
1602
1603 let payload = crate::introspect::to_node_payload_with(
1607 result,
1608 crate::introspect::NodeRef::Proc(proc_id.clone()),
1609 Some(crate::introspect::NodeRef::Host(agent.actor_addr().clone())),
1610 );
1611 if !matches!(payload.properties, NodeProperties::Error { .. }) {
1612 return Ok(payload);
1613 }
1614
1615 let mesh_agent_id = proc_id.actor_addr(PROC_AGENT_ACTOR_NAME);
1617 let result = query_child_introspect(
1618 cx,
1619 &mesh_agent_id,
1620 hyperactor::Addr::Proc(proc_id.clone()),
1621 hyperactor_config::global::get(crate::config::MESH_ADMIN_RESOLVE_ACTOR_TIMEOUT),
1622 "querying proc mesh agent",
1623 )
1624 .await?;
1625
1626 Ok(crate::introspect::to_node_payload_with(
1627 result,
1628 crate::introspect::NodeRef::Proc(proc_id.clone()),
1629 Some(crate::introspect::NodeRef::Host(agent.actor_addr().clone())),
1630 ))
1631 }
1632
1633 async fn resolve_standalone_proc_node(
1647 &self,
1648 cx: &Context<'_, Self>,
1649 proc_id: &ProcAddr,
1650 ) -> Result<NodePayload, anyhow::Error> {
1651 let actor_id = self
1652 .standalone_proc_anchor(proc_id)
1653 .ok_or_else(|| anyhow::anyhow!("no anchor actor for standalone proc {}", proc_id))?;
1654
1655 use crate::introspect::NodeRef;
1656
1657 let (children, system_children) = if self.self_actor_id.as_ref() == Some(actor_id) {
1658 let self_ref = NodeRef::Actor(actor_id.clone());
1659 (vec![self_ref.clone()], vec![self_ref])
1660 } else {
1661 let actor_result = query_introspect(
1662 cx,
1663 actor_id,
1664 hyperactor::introspect::IntrospectView::Actor,
1665 hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
1666 &format!("querying anchor actor on {}", proc_id),
1667 )
1668 .await?;
1669 let actor_payload = to_node_payload(actor_result);
1670 let anchor_ref = NodeRef::Actor(actor_id.clone());
1671 let anchor_is_system = matches!(
1672 &actor_payload.properties,
1673 NodeProperties::Actor {
1674 is_system: true,
1675 ..
1676 }
1677 );
1678
1679 let mut children = vec![anchor_ref.clone()];
1680 let mut system_children = Vec::new();
1681 if anchor_is_system {
1682 system_children.push(anchor_ref);
1683 }
1684
1685 for child_ref in actor_payload.children {
1686 let child_actor_id = match &child_ref {
1687 NodeRef::Actor(id) => Some(id),
1688 _ => None,
1689 };
1690 if let Some(child_actor_id) = child_actor_id {
1691 let child_is_system = if let Ok(r) = query_introspect(
1692 cx,
1693 child_actor_id,
1694 hyperactor::introspect::IntrospectView::Actor,
1695 hyperactor_config::global::get(
1696 crate::config::MESH_ADMIN_RESOLVE_ACTOR_TIMEOUT,
1697 ),
1698 "querying child actor is_system",
1699 )
1700 .await
1701 {
1702 let p = to_node_payload(r);
1703 matches!(
1704 &p.properties,
1705 NodeProperties::Actor {
1706 is_system: true,
1707 ..
1708 }
1709 )
1710 } else {
1711 false
1712 };
1713 if child_is_system {
1714 system_children.push(child_ref.clone());
1715 }
1716 }
1717 children.push(child_ref);
1718 }
1719 (children, system_children)
1720 };
1721
1722 let proc_name = proc_id
1723 .label()
1724 .map(|l| l.as_str().to_string())
1725 .unwrap_or_else(|| proc_id.id().to_string());
1726
1727 let mut attrs = hyperactor_config::Attrs::new();
1728 attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
1729 attrs.set(crate::introspect::PROC_NAME, proc_name.clone());
1730 attrs.set(crate::introspect::NUM_ACTORS, children.len());
1731 attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children.clone());
1732 let attrs_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
1733
1734 Ok(NodePayload {
1735 identity: NodeRef::Proc(proc_id.clone()),
1736 properties: crate::introspect::derive_properties(&attrs_json),
1737 children,
1738 as_of: std::time::SystemTime::now(),
1739 parent: Some(NodeRef::Root),
1740 })
1741 }
1742
1743 async fn resolve_actor_node(
1757 &mut self,
1758 cx: &Context<'_, Self>,
1759 actor_id: &hyperactor::ActorAddr,
1760 ) -> Result<NodePayload, anyhow::Error> {
1761 let result = if self.self_actor_id.as_ref() == Some(actor_id) {
1766 cx.introspect_payload()
1767 } else if self.is_standalone_proc_actor(actor_id) {
1768 query_introspect(
1770 cx,
1771 actor_id,
1772 hyperactor::introspect::IntrospectView::Actor,
1773 hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
1774 &format!("querying actor {}", actor_id),
1775 )
1776 .await?
1777 } else {
1778 let proc_id = actor_id.proc_addr();
1780 let mesh_agent_id = proc_id.actor_addr(PROC_AGENT_ACTOR_NAME);
1781 let terminated = query_child_introspect(
1782 cx,
1783 &mesh_agent_id,
1784 hyperactor::Addr::Actor(actor_id.clone()),
1785 hyperactor_config::global::get(crate::config::MESH_ADMIN_QUERY_CHILD_TIMEOUT),
1786 "querying terminated snapshot",
1787 )
1788 .await
1789 .ok()
1790 .filter(|r| {
1791 let p = crate::introspect::derive_properties(&r.attrs);
1792 !matches!(p, NodeProperties::Error { .. })
1793 });
1794
1795 match terminated {
1796 Some(snapshot) => snapshot,
1797 None => {
1798 query_introspect(
1800 cx,
1801 actor_id,
1802 hyperactor::introspect::IntrospectView::Actor,
1803 hyperactor_config::global::get(
1804 crate::config::MESH_ADMIN_RESOLVE_ACTOR_TIMEOUT,
1805 ),
1806 &format!("querying actor {}", actor_id),
1807 )
1808 .await?
1809 }
1810 }
1811 };
1812 let mut payload = to_node_payload(result);
1813
1814 if self.is_standalone_proc_actor(actor_id) {
1815 payload.parent = Some(crate::introspect::NodeRef::Proc(actor_id.proc_addr()));
1816 return Ok(payload);
1817 }
1818
1819 let proc_id = actor_id.proc_addr();
1820 match &payload.properties {
1821 NodeProperties::Proc { .. } => {
1822 if let Some(agent) = self.owning_host(cx, &proc_id).await? {
1823 payload.parent =
1824 Some(crate::introspect::NodeRef::Host(agent.actor_addr().clone()));
1825 }
1826 }
1827 _ => {
1828 payload.parent = Some(crate::introspect::NodeRef::Proc(proc_id.clone()));
1829 }
1830 }
1831
1832 Ok(payload)
1833 }
1834}
1835
1836fn create_mesh_admin_router(bridge_state: Arc<BridgeState>) -> Router {
1852 Router::new()
1853 .route("/SKILL.md", get(serve_skill_md))
1854 .route("/v1/admin", get(serve_admin_info))
1856 .route("/v1/schema", get(serve_schema))
1857 .route("/v1/schema/admin", get(serve_admin_schema))
1858 .route("/v1/schema/error", get(serve_error_schema))
1859 .route("/v1/openapi.json", get(serve_openapi))
1860 .route("/v1/tree", get(tree_dump))
1861 .route("/v1/query", post(query_proxy))
1862 .route("/v1/pyspy/{*proc_reference}", get(pyspy_bridge))
1863 .route(
1864 "/v1/pyspy_dump/{*proc_reference}",
1865 post(pyspy_dump_and_store),
1866 )
1867 .route(
1868 "/v1/pyspy_profile_svg/{*proc_reference}",
1869 post(pyspy_profile_svg),
1870 )
1871 .route("/v1/config/{*proc_reference}", get(config_bridge))
1872 .route("/v1/{*reference}", get(resolve_reference_bridge))
1873 .with_state(bridge_state)
1874}
1875
1876const SKILL_MD_TEMPLATE: &str = include_str!("mesh_admin_skill.md");
1878
1879fn extract_base_url(headers: &axum::http::HeaderMap) -> String {
1885 let host = headers
1886 .get(axum::http::header::HOST)
1887 .and_then(|v| v.to_str().ok())
1888 .unwrap_or("localhost");
1889 let scheme = headers
1890 .get("x-forwarded-proto")
1891 .and_then(|v| v.to_str().ok())
1892 .unwrap_or("https");
1893 format!("{scheme}://{host}")
1894}
1895
1896async fn serve_admin_info(
1899 State(state): State<Arc<BridgeState>>,
1900) -> axum::response::Json<AdminInfo> {
1901 axum::response::Json(state.admin_info.clone())
1902}
1903
1904async fn serve_admin_schema() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
1906 Ok(axum::response::Json(schema_with_id::<AdminInfo>(
1907 "https://monarch.meta.com/schemas/v1/admin_info",
1908 )?))
1909}
1910
1911async fn serve_skill_md(headers: axum::http::HeaderMap) -> impl axum::response::IntoResponse {
1914 let base = extract_base_url(&headers);
1915 let body = SKILL_MD_TEMPLATE.replace("{base}", &base);
1916 (
1917 [(
1918 axum::http::header::CONTENT_TYPE,
1919 "text/markdown; charset=utf-8",
1920 )],
1921 body,
1922 )
1923}
1924
1925fn schema_with_id<T: schemars::JsonSchema>(id: &str) -> Result<serde_json::Value, ApiError> {
1927 let schema = schemars::schema_for!(T);
1928 let mut value = serde_json::to_value(schema).map_err(|e| ApiError {
1929 code: "internal_error".to_string(),
1930 message: format!("failed to serialize schema: {e}"),
1931 details: None,
1932 })?;
1933 if let Some(obj) = value.as_object_mut() {
1934 obj.insert("$id".into(), serde_json::Value::String(id.into()));
1935 }
1936 Ok(value)
1937}
1938
1939async fn serve_schema() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
1941 Ok(axum::response::Json(schema_with_id::<NodePayloadDto>(
1942 "https://monarch.meta.com/schemas/v1/node_payload",
1943 )?))
1944}
1945
1946async fn serve_error_schema() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
1948 Ok(axum::response::Json(schema_with_id::<ApiErrorEnvelope>(
1949 "https://monarch.meta.com/schemas/v1/error",
1950 )?))
1951}
1952
1953fn hoist_defs(
1957 schema: &mut serde_json::Value,
1958 shared: &mut serde_json::Map<String, serde_json::Value>,
1959) {
1960 if let Some(obj) = schema.as_object_mut() {
1961 if let Some(defs) = obj.remove("$defs")
1962 && let Some(defs_map) = defs.as_object()
1963 {
1964 for (k, v) in defs_map {
1965 shared.insert(k.clone(), v.clone());
1966 }
1967 }
1968 obj.remove("$schema");
1972 }
1973 rewrite_refs(schema);
1974}
1975
1976fn rewrite_refs(value: &mut serde_json::Value) {
1979 match value {
1980 serde_json::Value::Object(map) => {
1981 if let Some(serde_json::Value::String(r)) = map.get_mut("$ref")
1982 && r.starts_with("#/$defs/")
1983 {
1984 *r = r.replace("#/$defs/", "#/components/schemas/");
1985 }
1986 for v in map.values_mut() {
1987 rewrite_refs(v);
1988 }
1989 }
1990 serde_json::Value::Array(arr) => {
1991 for v in arr {
1992 rewrite_refs(v);
1993 }
1994 }
1995 _ => {}
1996 }
1997}
1998
1999pub fn build_openapi_spec() -> serde_json::Value {
2002 let mut node_schema = serde_json::to_value(schemars::schema_for!(NodePayloadDto))
2003 .expect("NodePayload schema must be serializable");
2004 let mut error_schema = serde_json::to_value(schemars::schema_for!(ApiErrorEnvelope))
2005 .expect("ApiErrorEnvelope schema must be serializable");
2006 let mut pyspy_schema = serde_json::to_value(schemars::schema_for!(PySpyResult))
2007 .expect("PySpyResult schema must be serializable");
2008 let mut query_request_schema = serde_json::to_value(schemars::schema_for!(QueryRequest))
2009 .expect("QueryRequest schema must be serializable");
2010 let mut query_response_schema = serde_json::to_value(schemars::schema_for!(QueryResponse))
2011 .expect("QueryResponse schema must be serializable");
2012 let mut pyspy_dump_response_schema =
2013 serde_json::to_value(schemars::schema_for!(PyspyDumpAndStoreResponse))
2014 .expect("PyspyDumpAndStoreResponse schema must be serializable");
2015 let mut admin_info_schema = serde_json::to_value(schemars::schema_for!(AdminInfo))
2016 .expect("AdminInfo schema must be serializable");
2017 let mut profile_opts_schema = serde_json::to_value(schemars::schema_for!(PySpyProfileOpts))
2018 .expect("PySpyProfileOpts schema must be serializable");
2019
2020 let mut shared_schemas = serde_json::Map::new();
2023 hoist_defs(&mut node_schema, &mut shared_schemas);
2024 hoist_defs(&mut error_schema, &mut shared_schemas);
2025 hoist_defs(&mut pyspy_schema, &mut shared_schemas);
2026 hoist_defs(&mut query_request_schema, &mut shared_schemas);
2027 hoist_defs(&mut query_response_schema, &mut shared_schemas);
2028 hoist_defs(&mut pyspy_dump_response_schema, &mut shared_schemas);
2029 hoist_defs(&mut admin_info_schema, &mut shared_schemas);
2030 hoist_defs(&mut profile_opts_schema, &mut shared_schemas);
2031 shared_schemas.insert("NodePayload".into(), node_schema);
2032 shared_schemas.insert("ApiErrorEnvelope".into(), error_schema);
2033 shared_schemas.insert("PySpyResult".into(), pyspy_schema);
2034 shared_schemas.insert("QueryRequest".into(), query_request_schema);
2035 shared_schemas.insert("QueryResponse".into(), query_response_schema);
2036 shared_schemas.insert(
2037 "PyspyDumpAndStoreResponse".into(),
2038 pyspy_dump_response_schema,
2039 );
2040 shared_schemas.insert("AdminInfo".into(), admin_info_schema);
2041 shared_schemas.insert("PySpyProfileOpts".into(), profile_opts_schema);
2042
2043 for value in shared_schemas.values_mut() {
2045 rewrite_refs(value);
2046 }
2047
2048 let error_response = |desc: &str| -> serde_json::Value {
2049 serde_json::json!({
2050 "description": desc,
2051 "content": {
2052 "application/json": {
2053 "schema": { "$ref": "#/components/schemas/ApiErrorEnvelope" }
2054 }
2055 }
2056 })
2057 };
2058
2059 let success_payload = serde_json::json!({
2060 "description": "Resolved NodePayload",
2061 "content": {
2062 "application/json": {
2063 "schema": { "$ref": "#/components/schemas/NodePayload" }
2064 }
2065 }
2066 });
2067
2068 let mut spec = serde_json::json!({
2069 "openapi": "3.1.0",
2070 "info": {
2071 "title": "Monarch Mesh Admin API",
2072 "version": "1.0.0",
2073 "description": "Address-walking introspection API for a Monarch actor mesh. See the Admin Gateway Pattern RFC."
2074 },
2075 "paths": {
2076 "/v1/root": {
2077 "get": {
2078 "summary": "Fetch root node",
2079 "operationId": "getRoot",
2080 "responses": {
2081 "200": success_payload,
2082 "500": error_response("Internal error"),
2083 "503": error_response("Service unavailable (at capacity, retry with backoff)"),
2084 "504": error_response("Gateway timeout (downstream host unresponsive)")
2085 }
2086 }
2087 },
2088 "/v1/{reference}": {
2089 "get": {
2090 "summary": "Resolve a reference to a NodePayload",
2091 "operationId": "resolveReference",
2092 "parameters": [{
2093 "name": "reference",
2094 "in": "path",
2095 "required": true,
2096 "description": "URL-encoded opaque reference string",
2097 "schema": { "type": "string" }
2098 }],
2099 "responses": {
2100 "200": success_payload,
2101 "400": error_response("Bad request (malformed reference)"),
2102 "404": error_response("Address not found"),
2103 "500": error_response("Internal error"),
2104 "503": error_response("Service unavailable (at capacity, retry with backoff)"),
2105 "504": error_response("Gateway timeout (downstream host unresponsive)")
2106 }
2107 }
2108 },
2109 "/v1/schema": {
2110 "get": {
2111 "summary": "JSON Schema for NodePayload (Draft 2020-12)",
2112 "operationId": "getSchema",
2113 "responses": {
2114 "200": {
2115 "description": "JSON Schema document",
2116 "content": { "application/json": {} }
2117 }
2118 }
2119 }
2120 },
2121 "/v1/schema/error": {
2122 "get": {
2123 "summary": "JSON Schema for ApiErrorEnvelope (Draft 2020-12)",
2124 "operationId": "getErrorSchema",
2125 "responses": {
2126 "200": {
2127 "description": "JSON Schema document",
2128 "content": { "application/json": {} }
2129 }
2130 }
2131 }
2132 },
2133 "/v1/admin": {
2134 "get": {
2135 "summary": "Admin self-identification (placement, identity, URL)",
2136 "operationId": "getAdminInfo",
2137 "description": "Returns the admin actor's identity, proc placement, hostname, and URL. Used for placement verification and operational discovery.",
2138 "responses": {
2139 "200": {
2140 "description": "AdminInfo — admin actor placement metadata",
2141 "content": {
2142 "application/json": {
2143 "schema": { "$ref": "#/components/schemas/AdminInfo" }
2144 }
2145 }
2146 }
2147 }
2148 }
2149 },
2150 "/v1/tree": {
2151 "get": {
2152 "summary": "ASCII topology dump (debug)",
2153 "operationId": "getTree",
2154 "responses": {
2155 "200": {
2156 "description": "Human-readable topology tree",
2157 "content": { "text/plain": {} }
2158 }
2159 }
2160 }
2161 },
2162 "/v1/config/{proc_reference}": {
2163 "get": {
2164 "summary": "Config snapshot for a proc",
2165 "operationId": "getConfig",
2166 "description": "Returns the effective CONFIG-marked configuration entries from the target process. Routes to ProcAgent (worker procs) or HostAgent (service proc).",
2167 "parameters": [{
2168 "name": "proc_reference",
2169 "in": "path",
2170 "required": true,
2171 "description": "URL-encoded proc reference (ProcAddr)",
2172 "schema": { "type": "string" }
2173 }],
2174 "responses": {
2175 "200": {
2176 "description": "ConfigDumpResult — sorted list of config entries",
2177 "content": {
2178 "application/json": {
2179 "schema": {
2180 "type": "object",
2181 "properties": {
2182 "entries": {
2183 "type": "array",
2184 "items": {
2185 "type": "object",
2186 "properties": {
2187 "name": { "type": "string" },
2188 "value": { "type": "string" },
2189 "default_value": { "type": ["string", "null"] },
2190 "source": { "type": "string" },
2191 "changed_from_default": { "type": "boolean" },
2192 "env_var": { "type": ["string", "null"] }
2193 }
2194 }
2195 }
2196 }
2197 }
2198 }
2199 }
2200 },
2201 "404": error_response("Proc not found or handler not reachable"),
2202 "500": error_response("Internal error"),
2203 "504": error_response("Gateway timeout")
2204 }
2205 }
2206 },
2207 "/v1/pyspy/{proc_reference}": {
2208 "get": {
2209 "summary": "Py-spy stack dump for a proc",
2210 "operationId": "getPyspy",
2211 "description": "Runs py-spy against the target process and returns structured stack traces. Routes to ProcAgent (worker procs) or HostAgent (service proc).",
2212 "parameters": [{
2213 "name": "proc_reference",
2214 "in": "path",
2215 "required": true,
2216 "description": "URL-encoded proc reference (ProcAddr)",
2217 "schema": { "type": "string" }
2218 }],
2219 "responses": {
2220 "200": {
2221 "description": "PySpyResult — one of Ok, BinaryNotFound, or Failed",
2222 "content": {
2223 "application/json": {
2224 "schema": { "$ref": "#/components/schemas/PySpyResult" }
2225 }
2226 }
2227 },
2228 "400": error_response("Bad request (malformed proc reference)"),
2229 "404": error_response("Proc not found or handler not reachable"),
2230 "500": error_response("Internal error"),
2231 "504": error_response("Gateway timeout")
2232 }
2233 }
2234 },
2235 "/v1/query": {
2236 "post": {
2237 "summary": "Proxy SQL query to the telemetry dashboard",
2238 "operationId": "queryProxy",
2239 "description": "Forwards a SQL query to the Monarch dashboard's DataFusion engine. Requires telemetry_url to be configured.",
2240 "requestBody": {
2241 "required": true,
2242 "content": {
2243 "application/json": {
2244 "schema": { "$ref": "#/components/schemas/QueryRequest" }
2245 }
2246 }
2247 },
2248 "responses": {
2249 "200": {
2250 "description": "Query results",
2251 "content": {
2252 "application/json": {
2253 "schema": { "$ref": "#/components/schemas/QueryResponse" }
2254 }
2255 }
2256 },
2257 "400": error_response("Bad request (invalid SQL or missing sql field)"),
2258 "404": error_response("Dashboard not configured"),
2259 "500": error_response("Internal error"),
2260 "504": error_response("Gateway timeout")
2261 }
2262 }
2263 },
2264 "/v1/pyspy_dump/{proc_reference}": {
2265 "post": {
2266 "summary": "Trigger py-spy dump and store in telemetry",
2267 "operationId": "pyspyDumpAndStore",
2268 "description": "Runs py-spy against the target process, stores a successful result in the dashboard's DataFusion pyspy tables, and returns the dump_id. Failed captures return an error without a dump_id.",
2269 "parameters": [{
2270 "name": "proc_reference",
2271 "in": "path",
2272 "required": true,
2273 "description": "URL-encoded proc reference (ProcAddr)",
2274 "schema": { "type": "string" }
2275 }],
2276 "responses": {
2277 "200": {
2278 "description": "Dump stored successfully",
2279 "content": {
2280 "application/json": {
2281 "schema": { "$ref": "#/components/schemas/PyspyDumpAndStoreResponse" }
2282 }
2283 }
2284 },
2285 "400": error_response("Bad request (malformed proc reference)"),
2286 "404": error_response("Proc or dashboard not found"),
2287 "500": error_response("Capture, storage, or internal error"),
2288 "503": error_response("py-spy binary unavailable"),
2289 "504": error_response("Gateway timeout")
2290 }
2291 }
2292 }
2293 },
2294 "components": {
2295 "schemas": serde_json::Value::Object(shared_schemas)
2296 }
2297 });
2298
2299 if let Some(paths) = spec.pointer_mut("/paths").and_then(|v| v.as_object_mut()) {
2302 paths.insert(
2303 "/v1/schema/admin".into(),
2304 serde_json::json!({
2305 "get": {
2306 "summary": "JSON Schema for AdminInfo (Draft 2020-12)",
2307 "operationId": "getAdminSchema",
2308 "responses": {
2309 "200": {
2310 "description": "JSON Schema document",
2311 "content": { "application/json": {} }
2312 }
2313 }
2314 }
2315 }),
2316 );
2317 paths.insert(
2318 "/v1/pyspy_profile_svg/{proc_reference}".into(),
2319 serde_json::json!({
2320 "post": {
2321 "summary": "Profile a proc and return SVG flamegraph",
2322 "operationId": "pyspyProfileSvg",
2323 "description": "Runs py-spy record against the target process for the requested duration and returns an SVG flamegraph. Timeout scales with duration_s.",
2324 "parameters": [{
2325 "name": "proc_reference",
2326 "in": "path",
2327 "required": true,
2328 "description": "URL-encoded proc reference (ProcAddr)",
2329 "schema": { "type": "string" }
2330 }],
2331 "requestBody": {
2332 "required": true,
2333 "content": {
2334 "application/json": {
2335 "schema": { "$ref": "#/components/schemas/PySpyProfileOpts" }
2336 }
2337 }
2338 },
2339 "responses": {
2340 "200": {
2341 "description": "SVG flamegraph",
2342 "content": { "image/svg+xml": {} }
2343 },
2344 "400": error_response("Bad request (invalid duration/rate or malformed proc reference)"),
2345 "404": error_response("Proc not found or handler not reachable"),
2346 "500": error_response("Internal error (profile failed or SVG generation failed)"),
2347 "503": error_response("Service unavailable (py-spy not available on target host)"),
2348 "504": error_response("Gateway timeout (subprocess timed out)")
2349 }
2350 }
2351 }),
2352 );
2353 }
2354
2355 spec
2356}
2357
2358async fn serve_openapi() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
2360 Ok(axum::response::Json(build_openapi_spec()))
2361}
2362
2363fn parse_proc_reference(raw: &str) -> Result<(String, ProcAddr), ApiError> {
2366 let trimmed = raw.trim_start_matches('/');
2367 if trimmed.is_empty() {
2368 return Err(ApiError::bad_request("empty proc reference", None));
2369 }
2370 let decoded = urlencoding::decode(trimmed)
2371 .map(|cow| cow.into_owned())
2372 .map_err(|_| {
2373 ApiError::bad_request(
2374 "malformed percent-encoding: decoded bytes are not valid UTF-8",
2375 None,
2376 )
2377 })?;
2378 let proc_id: ProcAddr = decoded
2379 .parse()
2380 .map_err(|e| ApiError::bad_request(format!("invalid proc reference: {}", e), None))?;
2381 Ok((decoded, proc_id))
2382}
2383
2384async fn probe_actor(
2390 cx: &Instance<()>,
2391 agent_id: &hyperactor::ActorAddr,
2392) -> Result<bool, ApiError> {
2393 let port = agent_id.introspect_port();
2394 let (handle, rx) = open_once_port::<IntrospectResult>(cx);
2395 port.post(
2396 cx,
2397 IntrospectMessage::Query {
2398 view: IntrospectView::Entity,
2399 reply: handle.bind(),
2400 },
2401 );
2402
2403 let timeout = hyperactor_config::global::get(crate::config::MESH_ADMIN_QUERY_CHILD_TIMEOUT);
2404 match tokio::time::timeout(timeout, rx.recv()).await {
2405 Ok(Ok(_)) => Ok(true),
2406 Ok(Err(e)) => {
2407 tracing::debug!(
2408 name = "pyspy_probe_recv_failed",
2409 %agent_id,
2410 error = %e,
2411 );
2412 Ok(false)
2413 }
2414 Err(_elapsed) => {
2415 tracing::debug!(
2416 name = "pyspy_probe_timeout",
2417 %agent_id,
2418 );
2419 Ok(false)
2420 }
2421 }
2422}
2423
2424enum ResolvedProcHandler {
2431 Host(ActorRef<HostAgent>),
2432 Proc(ActorRef<ProcAgent>),
2433}
2434
2435impl ResolvedProcHandler {
2436 fn agent_id(&self) -> hyperactor::ActorAddr {
2437 match self {
2438 Self::Host(r) => r.actor_addr().clone(),
2439 Self::Proc(r) => r.actor_addr().clone(),
2440 }
2441 }
2442
2443 async fn pyspy_dump(
2444 &self,
2445 cx: &impl hyperactor::context::Actor,
2446 opts: PySpyOpts,
2447 timeout: std::time::Duration,
2448 ) -> Result<PySpyResult, ApiError> {
2449 let (reply_handle, reply_rx) = open_once_port::<PySpyResult>(cx);
2450 let mut reply_ref = reply_handle.bind();
2451 reply_ref.return_undeliverable(false);
2452 let msg = PySpyDump {
2453 opts,
2454 result: reply_ref,
2455 };
2456 match self {
2457 Self::Host(r) => r.post(cx, msg),
2458 Self::Proc(r) => r.post(cx, msg),
2459 };
2460 tokio::time::timeout(timeout, reply_rx.recv())
2461 .await
2462 .map_err(|_| ApiError {
2463 code: "gateway_timeout".to_string(),
2464 message: "timed out waiting for py-spy dump".to_string(),
2465 details: None,
2466 })?
2467 .map_err(|e| ApiError {
2468 code: "internal_error".to_string(),
2469 message: format!("failed to receive PySpyResult: {}", e),
2470 details: None,
2471 })
2472 }
2473
2474 async fn pyspy_profile(
2475 &self,
2476 cx: &impl hyperactor::context::Actor,
2477 request: ValidatedProfileRequest,
2478 timeout: std::time::Duration,
2479 ) -> Result<PySpyProfileResult, ApiError> {
2480 let (reply_handle, reply_rx) = open_once_port::<PySpyProfileResult>(cx);
2481 let mut reply_ref = reply_handle.bind();
2482 reply_ref.return_undeliverable(false);
2483 let msg = PySpyProfile {
2484 request,
2485 result: reply_ref,
2486 };
2487 match self {
2488 Self::Host(r) => r.post(cx, msg),
2489 Self::Proc(r) => r.post(cx, msg),
2490 };
2491 tokio::time::timeout(timeout, reply_rx.recv())
2492 .await
2493 .map_err(|_| ApiError {
2494 code: "gateway_timeout".to_string(),
2495 message: "timed out waiting for py-spy profile".to_string(),
2496 details: None,
2497 })?
2498 .map_err(|e| ApiError {
2499 code: "internal_error".to_string(),
2500 message: format!("failed to receive PySpyProfileResult: {}", e),
2501 details: None,
2502 })
2503 }
2504
2505 async fn config_dump(
2506 &self,
2507 cx: &impl hyperactor::context::Actor,
2508 timeout: std::time::Duration,
2509 ) -> Result<ConfigDumpResult, ApiError> {
2510 let (reply_handle, reply_rx) = open_once_port::<ConfigDumpResult>(cx);
2511 let mut reply_ref = reply_handle.bind();
2512 reply_ref.return_undeliverable(false);
2513 let msg = ConfigDump { result: reply_ref };
2514 match self {
2515 Self::Host(r) => r.post(cx, msg),
2516 Self::Proc(r) => r.post(cx, msg),
2517 };
2518 tokio::time::timeout(timeout, reply_rx.recv())
2519 .await
2520 .map_err(|_| ApiError {
2521 code: "gateway_timeout".to_string(),
2522 message: "timed out waiting for config dump".to_string(),
2523 details: None,
2524 })?
2525 .map_err(|e| ApiError {
2526 code: "internal_error".to_string(),
2527 message: format!("failed to receive ConfigDumpResult: {}", e),
2528 details: None,
2529 })
2530 }
2531}
2532
2533fn route_proc_handler(
2537 host_agents: &HashSet<HostAgentKey>,
2538 raw_proc_reference: &str,
2539) -> Result<ResolvedProcHandler, ApiError> {
2540 let (_proc_reference, proc_id) = parse_proc_reference(raw_proc_reference)?;
2541 let host_agent_id = proc_id.actor_addr(HOST_MESH_AGENT_ACTOR_NAME);
2542 if host_agents.contains(&HostAgentKey::new(&host_agent_id)) {
2543 return Ok(ResolvedProcHandler::Host(ActorRef::attest(host_agent_id)));
2544 }
2545
2546 let proc_agent_id = proc_id.actor_addr(PROC_AGENT_ACTOR_NAME);
2547 Ok(ResolvedProcHandler::Proc(ActorRef::attest(proc_agent_id)))
2548}
2549
2550async fn resolve_proc_handler(
2552 state: &BridgeState,
2553 raw_proc_reference: &str,
2554) -> Result<ResolvedProcHandler, ApiError> {
2555 let handler = route_proc_handler(&state.host_agents, raw_proc_reference)?;
2556 let cx = &state.bridge_cx;
2557 if !probe_actor(cx, &handler.agent_id()).await? {
2558 return Err(ApiError::not_found(
2559 format!(
2560 "proc does not have a reachable handler ({})",
2561 raw_proc_reference,
2562 ),
2563 None,
2564 ));
2565 }
2566 Ok(handler)
2567}
2568
2569async fn do_pyspy_dump(
2570 state: &BridgeState,
2571 raw_proc_reference: &str,
2572) -> Result<PySpyResult, ApiError> {
2573 let handler = resolve_proc_handler(state, raw_proc_reference).await?;
2574 let timeout = hyperactor_config::global::get(crate::config::MESH_ADMIN_PYSPY_BRIDGE_TIMEOUT);
2575 handler
2576 .pyspy_dump(
2577 &state.bridge_cx,
2578 PySpyOpts {
2579 threads: false,
2580 native: true,
2581 native_all: true,
2582 nonblocking: false,
2583 },
2584 timeout,
2585 )
2586 .await
2587}
2588
2589async fn pyspy_bridge(
2596 State(state): State<Arc<BridgeState>>,
2597 AxumPath(proc_reference): AxumPath<String>,
2598) -> Result<Json<PySpyResult>, ApiError> {
2599 Ok(Json(do_pyspy_dump(&state, &proc_reference).await?))
2600}
2601
2602async fn do_pyspy_profile(
2603 state: &BridgeState,
2604 raw_proc_reference: &str,
2605 opts: PySpyProfileOpts,
2606) -> Result<PySpyProfileResult, ApiError> {
2607 let max_duration =
2608 hyperactor_config::global::get(crate::config::MESH_ADMIN_PYSPY_MAX_PROFILE_DURATION);
2609 let request = ValidatedProfileRequest::try_new(&opts, max_duration)
2610 .map_err(|msg| ApiError::bad_request(msg, None))?;
2611 let bridge_timeout = request.bridge_timeout();
2612 let handler = resolve_proc_handler(state, raw_proc_reference).await?;
2613 handler
2614 .pyspy_profile(&state.bridge_cx, request, bridge_timeout)
2615 .await
2616}
2617
2618async fn pyspy_profile_svg(
2623 State(state): State<Arc<BridgeState>>,
2624 AxumPath(proc_reference): AxumPath<String>,
2625 Json(opts): Json<PySpyProfileOpts>,
2626) -> Result<axum::response::Response, ApiError> {
2627 let result = do_pyspy_profile(&state, &proc_reference, opts).await?;
2628 match result {
2629 PySpyProfileResult::Ok { svg, .. } => Ok(axum::response::Response::builder()
2630 .header("content-type", "image/svg+xml")
2631 .body(axum::body::Body::from(svg))
2632 .unwrap()),
2633 PySpyProfileResult::BinaryNotFound { searched } => Err(ApiError {
2634 code: "service_unavailable".to_string(),
2635 message: format!(
2636 "py-spy not available on target host; searched: {}",
2637 searched.join(", ")
2638 ),
2639 details: None,
2640 }),
2641 PySpyProfileResult::TimedOut {
2642 timeout_s, stderr, ..
2643 } => Err(ApiError {
2644 code: "gateway_timeout".to_string(),
2645 message: format!(
2646 "py-spy record subprocess timed out after {}s: {}",
2647 timeout_s,
2648 stderr.trim()
2649 ),
2650 details: None,
2651 }),
2652 PySpyProfileResult::ExitFailure { stderr, .. } => Err(ApiError {
2653 code: "profile_failed".to_string(),
2654 message: stderr,
2655 details: None,
2656 }),
2657 PySpyProfileResult::OutputMissing { pid, binary } => Err(ApiError {
2658 code: "profile_output_unusable".to_string(),
2659 message: format!("py-spy exited 0 but SVG file is missing (pid {pid}, {binary})"),
2660 details: None,
2661 }),
2662 PySpyProfileResult::OutputEmpty { pid, binary } => Err(ApiError {
2663 code: "profile_output_unusable".to_string(),
2664 message: format!("py-spy exited 0 but SVG output is empty (pid {pid}, {binary})"),
2665 details: None,
2666 }),
2667 PySpyProfileResult::OutputReadFailure { error, .. } => Err(ApiError {
2668 code: "internal_error".to_string(),
2669 message: format!("failed to read SVG output: {error}"),
2670 details: None,
2671 }),
2672 PySpyProfileResult::WorkerSpawnFailure { error } => Err(ApiError {
2673 code: "internal_error".to_string(),
2674 message: format!("failed to spawn profile worker actor: {error}"),
2675 details: None,
2676 }),
2677 PySpyProfileResult::SubprocessSpawnFailure { error, .. } => Err(ApiError {
2678 code: "internal_error".to_string(),
2679 message: format!("failed to execute py-spy: {error}"),
2680 details: None,
2681 }),
2682 PySpyProfileResult::WaitFailure { error, .. } => Err(ApiError {
2683 code: "internal_error".to_string(),
2684 message: format!("failed to wait for child: {error}"),
2685 details: None,
2686 }),
2687 PySpyProfileResult::TempDirFailure { error, .. } => Err(ApiError {
2688 code: "internal_error".to_string(),
2689 message: format!("failed to create temp dir: {error}"),
2690 details: None,
2691 }),
2692 }
2693}
2694
2695#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
2697pub struct QueryRequest {
2698 pub sql: String,
2700}
2701
2702#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
2704pub struct QueryResponse {
2705 pub rows: serde_json::Value,
2707}
2708
2709#[derive(Debug, Serialize)]
2711struct StorePyspyDumpRequest {
2712 dump_id: String,
2713 proc_ref: String,
2714 pyspy_result_json: String,
2715}
2716
2717#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
2719pub struct PyspyDumpAndStoreResponse {
2720 pub dump_id: String,
2722}
2723
2724fn serialize_storable_pyspy_result(pyspy_result: &PySpyResult) -> Result<String, ApiError> {
2725 match pyspy_result {
2726 PySpyResult::Ok { .. } => serde_json::to_string(pyspy_result).map_err(|e| ApiError {
2727 code: "internal_error".to_string(),
2728 message: format!("failed to serialize PySpyResult: {}", e),
2729 details: None,
2730 }),
2731 PySpyResult::BinaryNotFound { searched } => Err(ApiError {
2732 code: "service_unavailable".to_string(),
2733 message: format!(
2734 "py-spy not available on target host; searched: {}",
2735 searched.join(", ")
2736 ),
2737 details: None,
2738 }),
2739 PySpyResult::Failed {
2740 pid,
2741 binary,
2742 exit_code,
2743 stderr,
2744 } => {
2745 let exit_code =
2746 exit_code.map_or_else(|| "unknown".to_string(), |code| code.to_string());
2747 let stderr = stderr.trim();
2748 let message = if stderr.is_empty() {
2749 format!("py-spy dump failed for pid {pid} using {binary} (exit code {exit_code})")
2750 } else {
2751 format!(
2752 "py-spy dump failed for pid {pid} using {binary} (exit code {exit_code}): {stderr}"
2753 )
2754 };
2755 Err(ApiError {
2756 code: "pyspy_failed".to_string(),
2757 message,
2758 details: None,
2759 })
2760 }
2761 }
2762}
2763
2764fn require_telemetry_url(state: &BridgeState) -> Result<&str, ApiError> {
2767 state.telemetry_url.as_deref().ok_or_else(|| {
2768 ApiError::not_found("dashboard not configured (no telemetry_url provided)", None)
2769 })
2770}
2771
2772async fn query_proxy(
2779 State(state): State<Arc<BridgeState>>,
2780 axum::Json(body): axum::Json<QueryRequest>,
2781) -> Result<axum::Json<QueryResponse>, ApiError> {
2782 let telemetry_url = require_telemetry_url(&state)?;
2783
2784 let resp = state
2785 .http_client
2786 .post(format!("{}/api/query", telemetry_url))
2787 .json(&body)
2788 .send()
2789 .await
2790 .map_err(|e| ApiError {
2791 code: "proxy_error".to_string(),
2792 message: format!("failed to proxy query to dashboard: {}", e),
2793 details: None,
2794 })?;
2795
2796 let status = resp.status();
2797 let resp_body = resp.bytes().await.map_err(|e| ApiError {
2798 code: "proxy_error".to_string(),
2799 message: format!("failed to read dashboard response: {}", e),
2800 details: None,
2801 })?;
2802
2803 if !status.is_success() {
2804 let msg = serde_json::from_slice::<serde_json::Value>(&resp_body)
2806 .ok()
2807 .and_then(|v| v.get("error")?.as_str().map(String::from))
2808 .unwrap_or_else(|| format!("dashboard returned HTTP {status}"));
2809 let code = if status.is_client_error() {
2810 "bad_request"
2811 } else {
2812 "proxy_error"
2813 };
2814 return Err(ApiError {
2815 code: code.to_string(),
2816 message: msg,
2817 details: None,
2818 });
2819 }
2820
2821 let result: QueryResponse = serde_json::from_slice(&resp_body).map_err(|e| ApiError {
2822 code: "proxy_error".to_string(),
2823 message: format!("failed to parse dashboard response: {}", e),
2824 details: None,
2825 })?;
2826
2827 Ok(axum::Json(result))
2828}
2829
2830async fn pyspy_dump_and_store(
2840 State(state): State<Arc<BridgeState>>,
2841 AxumPath(proc_reference): AxumPath<String>,
2842) -> Result<axum::Json<PyspyDumpAndStoreResponse>, ApiError> {
2843 let telemetry_url = require_telemetry_url(&state)?;
2844 let pyspy_result = do_pyspy_dump(&state, &proc_reference).await?;
2845
2846 let pyspy_json = serialize_storable_pyspy_result(&pyspy_result)?;
2847 let dump_id = uuid::Uuid::new_v4().to_string();
2848
2849 let store_body = StorePyspyDumpRequest {
2850 dump_id: dump_id.clone(),
2851 proc_ref: proc_reference,
2852 pyspy_result_json: pyspy_json,
2853 };
2854
2855 let store_resp = state
2856 .http_client
2857 .post(format!("{}/api/pyspy_dump", telemetry_url))
2858 .json(&store_body)
2859 .send()
2860 .await
2861 .map_err(|e| ApiError {
2862 code: "proxy_error".to_string(),
2863 message: format!("failed to store pyspy dump in dashboard: {}", e),
2864 details: None,
2865 })?;
2866
2867 if !store_resp.status().is_success() {
2868 return Err(ApiError {
2869 code: "proxy_error".to_string(),
2870 message: format!(
2871 "dashboard rejected pyspy dump store: HTTP {}",
2872 store_resp.status()
2873 ),
2874 details: None,
2875 });
2876 }
2877
2878 Ok(axum::Json(PyspyDumpAndStoreResponse { dump_id }))
2879}
2880
2881async fn config_bridge(
2886 State(state): State<Arc<BridgeState>>,
2887 AxumPath(proc_reference): AxumPath<String>,
2888) -> Result<Json<ConfigDumpResult>, ApiError> {
2889 let handler = route_proc_handler(&state.host_agents, &proc_reference)?;
2890 let timeout =
2891 hyperactor_config::global::get(crate::config::MESH_ADMIN_CONFIG_DUMP_BRIDGE_TIMEOUT);
2892 let result = handler.config_dump(&state.bridge_cx, timeout).await?;
2893 Ok(Json(result))
2894}
2895
2896async fn resolve_reference_bridge(
2909 State(state): State<Arc<BridgeState>>,
2910 AxumPath(reference): AxumPath<String>,
2911) -> Result<Json<NodePayloadDto>, ApiError> {
2912 let reference = reference.trim_start_matches('/');
2914 if reference.is_empty() {
2915 return Err(ApiError::bad_request("empty reference", None));
2916 }
2917 let reference = urlencoding::decode(reference)
2918 .map(|cow| cow.into_owned())
2919 .map_err(|_| {
2920 ApiError::bad_request(
2921 "malformed percent-encoding: decoded bytes are not valid UTF-8",
2922 None,
2923 )
2924 })?;
2925
2926 let _permit = state.resolve_semaphore.try_acquire().map_err(|_| {
2929 tracing::warn!("mesh admin: rejecting resolve request (503): too many concurrent requests");
2930 ApiError {
2931 code: "service_unavailable".to_string(),
2932 message: "too many concurrent introspection requests".to_string(),
2933 details: None,
2934 }
2935 })?;
2936
2937 let cx = &state.bridge_cx;
2938 let resolve_start = std::time::Instant::now();
2939 let response = tokio::time::timeout(
2940 hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
2941 state.admin_ref.resolve(cx, reference.clone()),
2942 )
2943 .await
2944 .map_err(|_| {
2945 tracing::warn!(
2946 reference = %reference,
2947 elapsed_ms = resolve_start.elapsed().as_millis() as u64,
2948 "mesh admin: resolve timed out (gateway_timeout)",
2949 );
2950 ApiError {
2951 code: "gateway_timeout".to_string(),
2952 message: "timed out resolving reference".to_string(),
2953 details: None,
2954 }
2955 })?
2956 .map_err(|e| ApiError {
2957 code: "internal_error".to_string(),
2958 message: format!("failed to resolve reference: {}", e),
2959 details: None,
2960 })?;
2961
2962 match response.0 {
2963 Ok(payload) => Ok(Json(NodePayloadDto::from(payload))),
2964 Err(error) => Err(ApiError::not_found(error, None)),
2965 }
2966}
2967
2968async fn tree_dump(
2993 State(state): State<Arc<BridgeState>>,
2994 headers: axum::http::header::HeaderMap,
2995) -> Result<String, ApiError> {
2996 let _permit = state.resolve_semaphore.try_acquire().map_err(|_| {
2998 tracing::warn!(
2999 "mesh admin: rejecting tree_dump request (503): too many concurrent requests"
3000 );
3001 ApiError {
3002 code: "service_unavailable".to_string(),
3003 message: "too many concurrent introspection requests".to_string(),
3004 details: None,
3005 }
3006 })?;
3007
3008 let cx = &state.bridge_cx;
3009
3010 let host = headers
3012 .get("host")
3013 .and_then(|v| v.to_str().ok())
3014 .unwrap_or("localhost");
3015 let scheme = headers
3016 .get("x-forwarded-proto")
3017 .and_then(|v| v.to_str().ok())
3018 .unwrap_or("http");
3019 let base_url = format!("{}://{}", scheme, host);
3020
3021 let root_resp = tokio::time::timeout(
3023 hyperactor_config::global::get(crate::config::MESH_ADMIN_TREE_TIMEOUT),
3024 state.admin_ref.resolve(cx, "root".to_string()),
3025 )
3026 .await
3027 .map_err(|_| ApiError {
3028 code: "gateway_timeout".to_string(),
3029 message: "timed out resolving root".to_string(),
3030 details: None,
3031 })?
3032 .map_err(|e| ApiError {
3033 code: "internal_error".to_string(),
3034 message: format!("failed to resolve root: {}", e),
3035 details: None,
3036 })?;
3037
3038 let root = root_resp.0.map_err(|e| ApiError {
3039 code: "internal_error".to_string(),
3040 message: e,
3041 details: None,
3042 })?;
3043
3044 let mut output = String::new();
3045
3046 for child_ref in &root.children {
3050 let child_ref_str = child_ref.to_string();
3051 let resp = tokio::time::timeout(
3052 hyperactor_config::global::get(crate::config::MESH_ADMIN_TREE_TIMEOUT),
3053 state.admin_ref.resolve(cx, child_ref_str.clone()),
3054 )
3055 .await;
3056
3057 let payload = match resp {
3058 Ok(Ok(r)) => r.0.ok(),
3059 _ => None,
3060 };
3061
3062 match payload {
3063 Some(node) if matches!(node.properties, NodeProperties::Host { .. }) => {
3064 let header = match &node.properties {
3065 NodeProperties::Host { addr, .. } => addr.clone(),
3066 _ => child_ref_str.clone(),
3067 };
3068 let host_url = format!("{}/v1/{}", base_url, urlencoding::encode(&child_ref_str));
3069 output.push_str(&format!("{} -> {}\n", header, host_url));
3070
3071 let num_procs = node.children.len();
3072 for (i, proc_ref) in node.children.iter().enumerate() {
3073 let proc_ref_str = proc_ref.to_string();
3074 let is_last_proc = i == num_procs - 1;
3075 let proc_connector = if is_last_proc {
3076 "└── "
3077 } else {
3078 "├── "
3079 };
3080 let proc_name = derive_tree_label(proc_ref);
3081 let proc_url =
3082 format!("{}/v1/{}", base_url, urlencoding::encode(&proc_ref_str));
3083 output.push_str(&format!(
3084 "{}{} -> {}\n",
3085 proc_connector, proc_name, proc_url
3086 ));
3087
3088 let proc_resp = tokio::time::timeout(
3089 hyperactor_config::global::get(crate::config::MESH_ADMIN_TREE_TIMEOUT),
3090 state.admin_ref.resolve(cx, proc_ref_str),
3091 )
3092 .await;
3093 let proc_payload = match proc_resp {
3094 Ok(Ok(r)) => r.0.ok(),
3095 _ => None,
3096 };
3097 if let Some(proc_node) = proc_payload {
3098 let num_actors = proc_node.children.len();
3099 let child_prefix = if is_last_proc { " " } else { "│ " };
3100 for (j, actor_ref) in proc_node.children.iter().enumerate() {
3101 let actor_ref_str = actor_ref.to_string();
3102 let actor_connector = if j == num_actors - 1 {
3103 "└── "
3104 } else {
3105 "├── "
3106 };
3107 let actor_label = derive_actor_label(actor_ref);
3108 let actor_url =
3109 format!("{}/v1/{}", base_url, urlencoding::encode(&actor_ref_str));
3110 output.push_str(&format!(
3111 "{}{}{} -> {}\n",
3112 child_prefix, actor_connector, actor_label, actor_url
3113 ));
3114 }
3115 }
3116 }
3117 output.push('\n');
3118 }
3119 Some(node) if matches!(node.properties, NodeProperties::Proc { .. }) => {
3120 let proc_name = match &node.properties {
3121 NodeProperties::Proc { proc_name, .. } => proc_name.clone(),
3122 _ => child_ref_str.clone(),
3123 };
3124 let proc_url = format!("{}/v1/{}", base_url, urlencoding::encode(&child_ref_str));
3125 output.push_str(&format!("{} -> {}\n", proc_name, proc_url));
3126
3127 let num_actors = node.children.len();
3128 for (j, actor_ref) in node.children.iter().enumerate() {
3129 let actor_ref_str = actor_ref.to_string();
3130 let actor_connector = if j == num_actors - 1 {
3131 "└── "
3132 } else {
3133 "├── "
3134 };
3135 let actor_label = derive_actor_label(actor_ref);
3136 let actor_url =
3137 format!("{}/v1/{}", base_url, urlencoding::encode(&actor_ref_str));
3138 output.push_str(&format!(
3139 "{}{} -> {}\n",
3140 actor_connector, actor_label, actor_url
3141 ));
3142 }
3143 output.push('\n');
3144 }
3145 Some(_node) => {
3146 let label = derive_actor_label(child_ref);
3147 let url = format!("{}/v1/{}", base_url, urlencoding::encode(&child_ref_str));
3148 output.push_str(&format!("{} -> {}\n\n", label, url));
3149 }
3150 _ => {
3151 output.push_str(&format!("{} (unreachable)\n\n", child_ref));
3152 }
3153 }
3154 }
3155 Ok(output)
3156}
3157
3158fn derive_tree_label(node_ref: &crate::introspect::NodeRef) -> String {
3173 match node_ref {
3174 crate::introspect::NodeRef::Root => "root".to_string(),
3175 crate::introspect::NodeRef::Host(id) => id.proc_addr().id().to_string(),
3176 crate::introspect::NodeRef::Proc(id) => id.id().to_string(),
3177 crate::introspect::NodeRef::Actor(id) => {
3178 format!("{}[{}]", id.log_name(), id.uid())
3179 }
3180 }
3181}
3182
3183fn derive_actor_label(node_ref: &crate::introspect::NodeRef) -> String {
3184 match node_ref {
3185 crate::introspect::NodeRef::Root => "root".to_string(),
3186 crate::introspect::NodeRef::Host(id) => id.log_name().to_string(),
3187 crate::introspect::NodeRef::Proc(id) => id.id().to_string(),
3188 crate::introspect::NodeRef::Actor(id) => {
3189 format!("{}[{}]", id.log_name(), id.uid())
3190 }
3191 }
3192}
3193
3194#[non_exhaustive]
3203pub enum PublishedHandle {
3204 Mast(String),
3206}
3207
3208impl PublishedHandle {
3209 pub async fn resolve(self, _port_override: Option<u16>) -> anyhow::Result<String> {
3214 anyhow::bail!(
3215 "publication-based admin handle resolution is not yet implemented: \
3216 mesh admin placement has moved to the caller's local proc. \
3217 Discover the admin URL from startup output or another \
3218 launch-time publication instead."
3219 )
3220 }
3221}
3222
3223#[non_exhaustive]
3228pub enum AdminHandle {
3229 Url(String),
3231 Published(PublishedHandle),
3233 Unsupported(String),
3235}
3236
3237impl AdminHandle {
3238 pub fn parse(addr: &str) -> Self {
3247 if addr.starts_with("mast_conda:///") {
3249 return AdminHandle::Published(PublishedHandle::Mast(addr.to_string()));
3250 }
3251 if let Ok(parsed) = url::Url::parse(addr)
3253 && matches!(parsed.scheme(), "http" | "https")
3254 {
3255 return AdminHandle::Url(addr.to_string());
3256 }
3257 let with_scheme = format!("https://{}", addr);
3260 if let Ok(parsed) = url::Url::parse(&with_scheme)
3261 && parsed.host_str().is_some()
3262 && parsed.port().is_some()
3263 {
3264 return AdminHandle::Url(with_scheme);
3265 }
3266 AdminHandle::Unsupported(addr.to_string())
3267 }
3268
3269 pub async fn resolve(self, port_override: Option<u16>) -> anyhow::Result<String> {
3275 match self {
3276 AdminHandle::Url(url) => Ok(url),
3277 AdminHandle::Published(h) => h.resolve(port_override).await,
3278 AdminHandle::Unsupported(s) => anyhow::bail!(
3279 "unrecognized admin handle '{}': expected https://host:port or mast_conda:///job",
3280 s
3281 ),
3282 }
3283 }
3284}
3285
3286pub async fn resolve_mast_handle(
3292 handle: &str,
3293 port_override: Option<u16>,
3294) -> anyhow::Result<String> {
3295 AdminHandle::Published(PublishedHandle::Mast(handle.to_string()))
3296 .resolve(port_override)
3297 .await
3298}
3299
3300mod advertised_host {
3308 use std::net::IpAddr;
3309
3310 #[derive(Debug, PartialEq, Eq)]
3312 pub(super) enum SanIdentity {
3313 Ip(IpAddr),
3314 Dns(String),
3315 }
3316
3317 pub(super) fn from_cert_sans() -> String {
3329 let hostname = hostname::get()
3330 .unwrap_or_else(|_| "localhost".into())
3331 .into_string()
3332 .unwrap_or_else(|_| "localhost".to_string());
3333
3334 let mut candidates: Vec<(String, SanIdentity)> = Vec::new();
3338
3339 candidates.push((hostname.clone(), SanIdentity::Dns(hostname.clone())));
3341
3342 #[cfg(fbcode_build)]
3344 if let Ok(ip_str) = hyperactor::meta::host_ip::host_ipv6_address()
3345 && let Ok(ip) = ip_str.parse::<IpAddr>()
3346 {
3347 candidates.push((format!("[{}]", ip), SanIdentity::Ip(ip)));
3348 }
3349
3350 let cert_sans = load_cert_sans();
3351 let chosen = pick_candidate(&candidates, &cert_sans, &hostname);
3352
3353 if chosen != hostname && !cert_sans.is_empty() {
3354 tracing::info!("admin URL host '{}' matches cert SAN", chosen);
3355 } else if !cert_sans.is_empty() && !candidates.iter().any(|(_, id)| cert_sans.contains(id))
3356 {
3357 tracing::warn!(
3358 "no admin URL candidate matched cert SANs; falling back to hostname '{}'",
3359 hostname,
3360 );
3361 }
3362
3363 chosen
3364 }
3365
3366 fn load_cert_sans() -> Vec<SanIdentity> {
3373 use std::io::BufReader;
3374
3375 use x509_parser::prelude::*;
3376
3377 let bundle = match hyperactor::channel::try_tls_pem_bundle() {
3378 Some(b) => b,
3379 None => return Vec::new(),
3380 };
3381
3382 let cert_pem = match bundle.cert.reader() {
3383 Ok(r) => {
3384 let mut buf = Vec::new();
3385 if std::io::Read::read_to_end(&mut BufReader::new(r), &mut buf).is_err() {
3386 return Vec::new();
3387 }
3388 buf
3389 }
3390 Err(_) => return Vec::new(),
3391 };
3392
3393 let mut cursor = &cert_pem[..];
3394 let certs: Vec<_> = rustls_pemfile::certs(&mut cursor)
3395 .filter_map(|r| r.ok())
3396 .collect();
3397
3398 let leaf_der = match certs.first() {
3399 Some(c) => c,
3400 None => return Vec::new(),
3401 };
3402
3403 let (_, cert) = match X509Certificate::from_der(leaf_der.as_ref()) {
3404 Ok(parsed) => parsed,
3405 Err(e) => {
3406 tracing::warn!("failed to parse leaf cert for SAN extraction: {}", e);
3407 return Vec::new();
3408 }
3409 };
3410
3411 let mut sans = Vec::new();
3412 if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
3413 for name in &san_ext.value.general_names {
3414 match name {
3415 GeneralName::DNSName(dns) => {
3416 sans.push(SanIdentity::Dns(dns.to_string()));
3417 }
3418 GeneralName::IPAddress(bytes) => {
3419 let ip = match bytes.len() {
3420 4 => IpAddr::from(<[u8; 4]>::try_from(*bytes).unwrap()),
3421 16 => IpAddr::from(<[u8; 16]>::try_from(*bytes).unwrap()),
3422 _ => continue,
3423 };
3424 sans.push(SanIdentity::Ip(ip));
3425 }
3426 _ => {}
3427 }
3428 }
3429 }
3430
3431 sans
3432 }
3433
3434 fn pick_candidate(
3437 candidates: &[(String, SanIdentity)],
3438 cert_sans: &[SanIdentity],
3439 fallback: &str,
3440 ) -> String {
3441 if cert_sans.is_empty() {
3442 return fallback.to_string();
3443 }
3444 for (url_host, identity) in candidates {
3445 if cert_sans.iter().any(|san| san == identity) {
3446 return url_host.clone();
3447 }
3448 }
3449 fallback.to_string()
3450 }
3451
3452 #[cfg(test)]
3453 mod tests {
3454 use std::net::IpAddr;
3455 use std::net::Ipv4Addr;
3456 use std::net::Ipv6Addr;
3457
3458 use super::*;
3459
3460 #[test]
3461 fn cert_covers_hostname_only_picks_hostname() {
3462 let candidates = vec![
3463 ("myhost".to_string(), SanIdentity::Dns("myhost".to_string())),
3464 (
3465 "[::1]".to_string(),
3466 SanIdentity::Ip(IpAddr::V6(Ipv6Addr::LOCALHOST)),
3467 ),
3468 ];
3469 let sans = vec![SanIdentity::Dns("myhost".to_string())];
3470 assert_eq!(pick_candidate(&candidates, &sans, "fallback"), "myhost");
3471 }
3472
3473 #[test]
3474 fn cert_covers_ip_only_picks_ip() {
3475 let ip = IpAddr::V6("2803:6084:3894:2b36:b5d3:11ef:400:0".parse().unwrap());
3476 let candidates = vec![
3477 ("myhost".to_string(), SanIdentity::Dns("myhost".to_string())),
3478 (format!("[{}]", ip), SanIdentity::Ip(ip)),
3479 ];
3480 let sans = vec![SanIdentity::Ip(ip)];
3481 assert_eq!(
3482 pick_candidate(&candidates, &sans, "fallback"),
3483 format!("[{}]", ip)
3484 );
3485 }
3486
3487 #[test]
3488 fn cert_covers_both_prefers_hostname() {
3489 let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
3490 let candidates = vec![
3491 ("myhost".to_string(), SanIdentity::Dns("myhost".to_string())),
3492 ("10.0.0.1".to_string(), SanIdentity::Ip(ip)),
3493 ];
3494 let sans = vec![SanIdentity::Dns("myhost".to_string()), SanIdentity::Ip(ip)];
3495 assert_eq!(pick_candidate(&candidates, &sans, "fallback"), "myhost");
3496 }
3497
3498 #[test]
3499 fn no_sans_returns_fallback() {
3500 let candidates = vec![("myhost".to_string(), SanIdentity::Dns("myhost".to_string()))];
3501 assert_eq!(pick_candidate(&candidates, &[], "fallback"), "fallback");
3502 }
3503
3504 #[test]
3505 fn no_candidate_matches_returns_fallback() {
3506 let candidates = vec![("myhost".to_string(), SanIdentity::Dns("myhost".to_string()))];
3507 let sans = vec![SanIdentity::Dns("otherhost".to_string())];
3508 assert_eq!(pick_candidate(&candidates, &sans, "fallback"), "fallback");
3509 }
3510 }
3511}
3512
3513#[cfg(test)]
3514mod tests {
3515 use std::net::SocketAddr;
3516
3517 use hyperactor::ProcId;
3518 use hyperactor::channel::ChannelAddr;
3519 use hyperactor::id::Label;
3520 use hyperactor::testing::ids::test_proc_id_with_addr;
3521
3522 use super::*;
3523 use crate::host::SERVICE_PROC_NAME;
3524 use crate::mesh_id::ResourceId;
3525 use crate::pyspy::PySpyCaptureMode;
3526
3527 #[test]
3534 fn failed_pyspy_dump_is_rejected_before_storage() {
3535 let error = serialize_storable_pyspy_result(&PySpyResult::Failed {
3536 pid: 1234,
3537 binary: "py-spy".to_string(),
3538 exit_code: Some(1),
3539 stderr: "capture failed".to_string(),
3540 })
3541 .expect_err("a failed capture must not be sent to storage");
3542
3543 assert_eq!(error.code, "pyspy_failed");
3544 assert_eq!(
3545 error.message,
3546 "py-spy dump failed for pid 1234 using py-spy (exit code 1): capture failed"
3547 );
3548 assert_eq!(
3549 error.into_response().status(),
3550 StatusCode::INTERNAL_SERVER_ERROR
3551 );
3552 }
3553
3554 #[test]
3555 fn missing_pyspy_binary_is_rejected_before_storage() {
3556 let error = serialize_storable_pyspy_result(&PySpyResult::BinaryNotFound {
3557 searched: vec!["configured-py-spy".to_string(), "py-spy".to_string()],
3558 })
3559 .expect_err("a missing binary must not be sent to storage");
3560
3561 assert_eq!(error.code, "service_unavailable");
3562 assert_eq!(
3563 error.message,
3564 "py-spy not available on target host; searched: configured-py-spy, py-spy"
3565 );
3566 assert_eq!(
3567 error.into_response().status(),
3568 StatusCode::SERVICE_UNAVAILABLE
3569 );
3570 }
3571
3572 #[test]
3573 fn successful_pyspy_dump_is_serialized_for_storage() {
3574 let result = PySpyResult::Ok {
3575 pid: 1234,
3576 binary: "py-spy".to_string(),
3577 capture_mode: PySpyCaptureMode::PythonOnly,
3578 stack_traces: vec![],
3579 warnings: vec![],
3580 };
3581
3582 let serialized = serialize_storable_pyspy_result(&result)
3583 .expect("a successful capture should be sent to storage");
3584
3585 assert_eq!(
3586 serde_json::from_str::<PySpyResult>(&serialized).unwrap(),
3587 result
3588 );
3589 }
3590
3591 #[test]
3592 fn mesh_admin_access_instructions_print_file_tls_paths() {
3593 let bundle = PemBundle {
3594 ca: Pem::File("/tmp/custom-ca.pem".into()),
3595 cert: Pem::File("/tmp/custom-cert.pem".into()),
3596 key: Pem::File("/tmp/custom-key.pem".into()),
3597 };
3598
3599 let instructions =
3600 mesh_admin_access_instructions("https://admin.example.com", Some(&bundle));
3601
3602 assert!(instructions.contains(
3603 "Root node: curl --cacert /tmp/custom-ca.pem --cert /tmp/custom-cert.pem --key /tmp/custom-key.pem https://admin.example.com/v1/root"
3604 ));
3605 }
3606
3607 #[test]
3608 fn mesh_admin_access_instructions_print_static_tls_paths() {
3609 let bundle = PemBundle {
3610 ca: Pem::StaticPath("/etc/hyperactor/tls/ca.crt"),
3611 cert: Pem::StaticPath("/etc/hyperactor/tls/tls.crt"),
3612 key: Pem::StaticPath("/etc/hyperactor/tls/tls.key"),
3613 };
3614
3615 let instructions =
3616 mesh_admin_access_instructions("https://admin.example.com", Some(&bundle));
3617
3618 assert!(instructions.contains(
3619 "Root node: curl --cacert /etc/hyperactor/tls/ca.crt --cert /etc/hyperactor/tls/tls.crt --key /etc/hyperactor/tls/tls.key https://admin.example.com/v1/root"
3620 ));
3621 }
3622
3623 #[test]
3624 fn mesh_admin_access_instructions_do_not_render_pem_values_as_curl() {
3625 let bundle = PemBundle {
3626 ca: Pem::Value(b"ca".to_vec()),
3627 cert: Pem::File("/tmp/custom-cert.pem".into()),
3628 key: Pem::File("/tmp/custom-key.pem".into()),
3629 };
3630
3631 let instructions =
3632 mesh_admin_access_instructions("https://admin.example.com", Some(&bundle));
3633
3634 assert!(instructions.contains("Root node: https://admin.example.com/v1/root"));
3635 assert!(!instructions.contains("Root node: curl"));
3636 }
3637
3638 #[test]
3639 fn mesh_admin_access_instructions_print_plain_http_curl() {
3640 let instructions = mesh_admin_access_instructions("http://localhost:1729", None);
3641
3642 assert!(instructions.contains("Root node: curl http://localhost:1729/v1/root"));
3643 }
3644
3645 #[derive(Debug)]
3650 #[hyperactor::export(handlers = [])]
3651 struct TestIntrospectableActor;
3652 impl Actor for TestIntrospectableActor {}
3653
3654 #[test]
3659 fn test_build_root_payload() {
3660 let addr1: SocketAddr = "127.0.0.1:9001".parse().unwrap();
3661 let addr2: SocketAddr = "127.0.0.1:9002".parse().unwrap();
3662
3663 let proc1 = test_proc_id_with_addr(ChannelAddr::Tcp(addr1), "host1");
3664 let proc2 = test_proc_id_with_addr(ChannelAddr::Tcp(addr2), "host2");
3665
3666 let actor_id1 = proc1.actor_addr("mesh_agent");
3667 let actor_id2 = proc2.actor_addr("mesh_agent");
3668
3669 let ref1: ActorRef<HostAgent> = ActorRef::attest(actor_id1.clone());
3670 let ref2: ActorRef<HostAgent> = ActorRef::attest(actor_id2.clone());
3671
3672 let agent = MeshAdminAgent::new(
3673 vec![("host_a".to_string(), ref1), ("host_b".to_string(), ref2)],
3674 None,
3675 None,
3676 None,
3677 );
3678
3679 let payload = agent.build_root_payload();
3680 assert_eq!(payload.identity, crate::introspect::NodeRef::Root);
3681 assert_eq!(payload.parent, None);
3682 assert!(matches!(
3683 payload.properties,
3684 NodeProperties::Root { num_hosts: 2, .. }
3685 ));
3686 assert_eq!(payload.children.len(), 2);
3687 assert!(
3688 payload
3689 .children
3690 .contains(&crate::introspect::NodeRef::Host(actor_id1.clone()))
3691 );
3692 assert!(
3693 payload
3694 .children
3695 .contains(&crate::introspect::NodeRef::Host(actor_id2.clone()))
3696 );
3697
3698 match &payload.properties {
3700 NodeProperties::Root {
3701 num_hosts,
3702 started_by,
3703 system_children,
3704 ..
3705 } => {
3706 assert_eq!(*num_hosts, 2);
3707 assert!(!started_by.is_empty());
3708 assert!(
3710 system_children.is_empty(),
3711 "LC-1: root system_children must be empty"
3712 );
3713 }
3714 other => panic!("expected Root, got {:?}", other),
3715 }
3716 }
3717
3718 fn hosts_at_one_address() -> (ChannelAddr, ActorRef<HostAgent>, ActorRef<HostAgent>) {
3720 let addr = ChannelAddr::Tcp("10.0.0.1:26600".parse().unwrap());
3721 let a =
3722 ActorRef::attest(test_proc_id_with_addr(addr.clone(), "a").actor_addr("host_agent"));
3723 let b =
3724 ActorRef::attest(test_proc_id_with_addr(addr.clone(), "b").actor_addr("host_agent"));
3725 (addr, a, b)
3726 }
3727
3728 fn host_key(host: &ActorRef<HostAgent>) -> HostAgentKey {
3729 HostAgentKey::new(host.actor_addr())
3730 }
3731
3732 fn procs_at(addr: &ChannelAddr, names: &[&str]) -> HashSet<ProcAddr> {
3733 names
3734 .iter()
3735 .map(|name| test_proc_id_with_addr(addr.clone(), name))
3736 .collect()
3737 }
3738
3739 fn admin_for(addr: &ChannelAddr, hosts: &[&ActorRef<HostAgent>]) -> MeshAdminAgent {
3740 MeshAdminAgent::new(
3741 hosts
3742 .iter()
3743 .map(|host| (addr.to_string(), (*host).clone()))
3744 .collect(),
3745 None,
3746 None,
3747 None,
3748 )
3749 }
3750
3751 #[test]
3754 fn test_root_lists_hosts_sharing_an_address() {
3755 let (addr, a, b) = hosts_at_one_address();
3756 let agent = admin_for(&addr, &[&a, &b]);
3757
3758 let payload = agent.build_root_payload();
3759 assert!(matches!(
3760 payload.properties,
3761 NodeProperties::Root { num_hosts: 2, .. }
3762 ));
3763 assert_eq!(
3764 payload.children,
3765 vec![
3766 crate::introspect::NodeRef::Host(a.actor_addr().clone()),
3767 crate::introspect::NodeRef::Host(b.actor_addr().clone()),
3768 ]
3769 );
3770 }
3771
3772 #[test]
3773 fn test_host_snapshot_replaces_only_that_hosts_procs() {
3774 let (addr, a, b) = hosts_at_one_address();
3775 let mut agent = admin_for(&addr, &[&a, &b]);
3776 agent
3777 .apply_host_snapshots(vec![
3778 (host_key(&a), procs_at(&addr, &["stale", "kept"])),
3779 (host_key(&b), procs_at(&addr, &["other"])),
3780 ])
3781 .unwrap();
3782
3783 agent
3784 .apply_host_snapshots(vec![(host_key(&a), procs_at(&addr, &["kept", "new"]))])
3785 .unwrap();
3786
3787 let owner = |name| {
3788 agent
3789 .proc_owners
3790 .get(&test_proc_id_with_addr(addr.clone(), name))
3791 };
3792 assert_eq!(owner("stale"), None);
3793 assert_eq!(owner("kept"), Some(&host_key(&a)));
3794 assert_eq!(owner("new"), Some(&host_key(&a)));
3795 assert_eq!(owner("other"), Some(&host_key(&b)));
3796 }
3797
3798 #[test]
3799 fn test_host_snapshot_claiming_another_hosts_proc_changes_nothing() {
3800 let (addr, a, b) = hosts_at_one_address();
3801 let mut agent = admin_for(&addr, &[&a, &b]);
3802 agent
3803 .apply_host_snapshots(vec![(host_key(&a), procs_at(&addr, &["p"]))])
3804 .unwrap();
3805 let before = agent.proc_owners.clone();
3806
3807 let err = agent
3808 .apply_host_snapshots(vec![(host_key(&b), procs_at(&addr, &["p", "q"]))])
3809 .unwrap_err();
3810
3811 assert!(err.to_string().contains("claimed by"), "{err}");
3812 assert_eq!(agent.proc_owners, before);
3813 }
3814
3815 #[test]
3816 fn test_host_snapshots_claiming_one_proc_change_nothing() {
3817 let (addr, a, b) = hosts_at_one_address();
3818 let mut agent = admin_for(&addr, &[&a, &b]);
3819
3820 let err = agent
3821 .apply_host_snapshots(vec![
3822 (host_key(&a), procs_at(&addr, &["p"])),
3823 (host_key(&b), procs_at(&addr, &["p"])),
3824 ])
3825 .unwrap_err();
3826
3827 assert!(err.to_string().contains("claimed by"), "{err}");
3828 assert!(agent.proc_owners.is_empty());
3829 }
3830
3831 #[test]
3832 fn test_snapshot_from_unconfigured_host_changes_nothing() {
3833 let (addr, a, b) = hosts_at_one_address();
3834 let mut agent = admin_for(&addr, &[&a]);
3835 agent
3836 .apply_host_snapshots(vec![(host_key(&a), procs_at(&addr, &["p"]))])
3837 .unwrap();
3838 let before = agent.proc_owners.clone();
3839
3840 let err = agent
3841 .apply_host_snapshots(vec![(host_key(&b), procs_at(&addr, &["q"]))])
3842 .unwrap_err();
3843
3844 assert!(err.to_string().contains("not configured"), "{err}");
3845 assert_eq!(agent.proc_owners, before);
3846 }
3847
3848 #[test]
3849 fn test_registration_order_does_not_change_owner() {
3850 let (addr, a, b) = hosts_at_one_address();
3851 for order in [[&a, &b], [&b, &a]] {
3852 let mut agent = admin_for(&addr, &order);
3853 agent
3854 .apply_host_snapshots(vec![
3855 (host_key(&b), HashSet::new()),
3856 (host_key(&a), procs_at(&addr, &["p"])),
3857 ])
3858 .unwrap();
3859 assert_eq!(
3860 agent
3861 .proc_owners
3862 .get(&test_proc_id_with_addr(addr.clone(), "p")),
3863 Some(&host_key(&a))
3864 );
3865 }
3866 }
3867
3868 #[derive(Debug)]
3872 #[hyperactor::export(handlers = [PublishProcs])]
3873 struct StandInHost;
3874 impl Actor for StandInHost {}
3875
3876 #[derive(Debug, Serialize, Deserialize, Named)]
3878 struct PublishProcs(Vec<ProcAddr>, OncePortRef<()>);
3879 wirevalue::register_type!(PublishProcs);
3880
3881 #[async_trait]
3882 impl Handler<PublishProcs> for StandInHost {
3883 async fn handle(
3884 &mut self,
3885 cx: &Context<Self>,
3886 PublishProcs(procs, ack): PublishProcs,
3887 ) -> Result<(), anyhow::Error> {
3888 let mut attrs = hyperactor_config::Attrs::new();
3889 attrs.set(crate::introspect::NODE_TYPE, "host".to_string());
3890 attrs.set(crate::introspect::ADDR, cx.self_addr().addr().to_string());
3891 attrs.set(crate::introspect::NUM_PROCS, procs.len());
3892 attrs.set(
3893 hyperactor::introspect::CHILDREN,
3894 procs
3895 .into_iter()
3896 .map(hyperactor::introspect::IntrospectRef::Proc)
3897 .collect(),
3898 );
3899 attrs.set(crate::introspect::SYSTEM_CHILDREN, Vec::new());
3900 cx.publish_attrs(attrs);
3901 ack.post(cx, ());
3902 Ok(())
3903 }
3904 }
3905
3906 struct SharedAddress {
3912 proc: hyperactor::Proc,
3913 client: hyperactor::Client,
3914 a: ActorRef<HostAgent>,
3915 b: ActorRef<HostAgent>,
3916 }
3917
3918 impl SharedAddress {
3919 async fn new() -> Self {
3920 use hyperactor::actor::ActorStatus;
3921 use hyperactor::channel::ChannelTransport;
3922
3923 let proc = hyperactor::Proc::direct(ChannelTransport::Unix.any(), "shared".to_string())
3924 .unwrap();
3925 let agent = crate::proc_agent::ProcAgent::boot_v1(proc.clone(), None).unwrap();
3926 tokio::time::timeout(
3927 Duration::from_secs(10),
3928 agent.status().wait_for(|s| matches!(s, ActorStatus::Idle)),
3929 )
3930 .await
3931 .expect("proc agent did not become idle")
3932 .unwrap();
3933 let client = proc.client("client");
3934 let a = Self::stand_in(&proc, "host_a");
3935 let b = Self::stand_in(&proc, "host_b");
3936 Self { proc, client, a, b }
3937 }
3938
3939 fn stand_in(proc: &hyperactor::Proc, label: &str) -> ActorRef<HostAgent> {
3940 ActorRef::attest(
3941 proc.spawn_with_label(label, StandInHost)
3942 .bind::<StandInHost>()
3943 .actor_addr()
3944 .clone(),
3945 )
3946 }
3947
3948 fn silent(&self) -> ActorRef<HostAgent> {
3950 ActorRef::attest(self.proc.proc_addr().actor_addr("silent_host"))
3951 }
3952
3953 async fn publish(&self, host: &ActorRef<HostAgent>, procs: &[&hyperactor::Proc]) {
3954 let (ack, done) = open_once_port::<()>(&self.client);
3955 ActorRef::<StandInHost>::attest(host.actor_addr().clone()).post(
3956 &self.client,
3957 PublishProcs(
3958 procs.iter().map(|proc| proc.proc_addr()).collect(),
3959 ack.bind(),
3960 ),
3961 );
3962 tokio::time::timeout(Duration::from_secs(10), done.recv())
3963 .await
3964 .expect("stand-in host did not acknowledge its republish")
3965 .unwrap();
3966 }
3967
3968 async fn admin(&self, hosts: &[&ActorRef<HostAgent>]) -> Admin {
3969 use hyperactor::channel::ChannelTransport;
3970
3971 let addr = self.proc.proc_addr().addr().to_string();
3972 let proc = hyperactor::Proc::direct(ChannelTransport::Unix.any(), "admin".to_string())
3973 .unwrap();
3974 let supervision =
3975 hyperactor::testing::proc_supervison::ProcSupervisionCoordinator::set(&proc)
3976 .await
3977 .unwrap();
3978 let admin_ref = proc
3979 .spawn_with_label(
3980 MESH_ADMIN_ACTOR_NAME,
3981 MeshAdminAgent::new(
3982 hosts
3983 .iter()
3984 .map(|host| (addr.clone(), (*host).clone()))
3985 .collect(),
3986 None,
3987 Some("[::]:0".parse().unwrap()),
3988 None,
3989 ),
3990 )
3991 .bind();
3992 Admin {
3993 admin_ref,
3994 _proc: proc,
3995 _supervision: supervision,
3996 }
3997 }
3998
3999 async fn resolve(
4000 &self,
4001 admin: &Admin,
4002 node: crate::introspect::NodeRef,
4003 ) -> Result<NodePayload, String> {
4004 tokio::time::timeout(
4005 Duration::from_secs(10),
4006 admin.admin_ref.resolve(&self.client, node.to_string()),
4007 )
4008 .await
4009 .expect("admin did not answer the resolve")
4010 .unwrap()
4011 .0
4012 }
4013
4014 async fn resolve_proc(&self, admin: &Admin) -> Result<NodePayload, String> {
4015 self.resolve(
4016 admin,
4017 crate::introspect::NodeRef::Proc(self.proc.proc_addr()),
4018 )
4019 .await
4020 }
4021 }
4022
4023 struct Admin {
4024 admin_ref: ActorRef<MeshAdminAgent>,
4025 _proc: hyperactor::Proc,
4026 _supervision: (
4027 hyperactor::testing::proc_supervison::ReportedEvent,
4028 ActorHandle<hyperactor::testing::proc_supervison::ProcSupervisionCoordinator>,
4029 ),
4030 }
4031
4032 fn host_ref(host: &ActorRef<HostAgent>) -> Option<crate::introspect::NodeRef> {
4033 Some(crate::introspect::NodeRef::Host(host.actor_addr().clone()))
4034 }
4035
4036 #[tokio::test]
4039 async fn test_host_resolve_records_proc_owner() {
4040 let shared = SharedAddress::new().await;
4041 let admin = shared.admin(&[&shared.a, &shared.b]).await;
4042 shared.publish(&shared.a, &[&shared.proc]).await;
4043 shared.publish(&shared.b, &[]).await;
4044
4045 shared
4046 .resolve(
4047 &admin,
4048 crate::introspect::NodeRef::Host(shared.a.actor_addr().clone()),
4049 )
4050 .await
4051 .unwrap();
4052 shared.publish(&shared.a, &[]).await;
4053 shared.publish(&shared.b, &[&shared.proc]).await;
4054
4055 let proc = shared.resolve_proc(&admin).await.unwrap();
4056 assert_eq!(proc.parent, host_ref(&shared.a));
4057 }
4058
4059 #[tokio::test]
4062 async fn test_shared_address_proc_resolve_asks_the_candidates() {
4063 let shared = SharedAddress::new().await;
4064 for order in [[&shared.a, &shared.b], [&shared.b, &shared.a]] {
4065 let admin = shared.admin(&order).await;
4066 shared.publish(&shared.a, &[&shared.proc]).await;
4067 shared.publish(&shared.b, &[]).await;
4068
4069 let proc = shared.resolve_proc(&admin).await.unwrap();
4070 assert_eq!(proc.parent, host_ref(&shared.a));
4071
4072 shared.publish(&shared.a, &[]).await;
4073 shared.publish(&shared.b, &[&shared.proc]).await;
4074 let proc = shared.resolve_proc(&admin).await.unwrap();
4075 assert_eq!(proc.parent, host_ref(&shared.a));
4076 }
4077 }
4078
4079 #[tokio::test]
4082 async fn test_unconfigured_host_resolve_is_read_only() {
4083 let shared = SharedAddress::new().await;
4084 let other = SharedAddress::stand_in(&shared.proc, "host_c");
4085 let admin = shared.admin(&[&shared.a, &shared.b]).await;
4086 shared.publish(&shared.a, &[&shared.proc]).await;
4087 shared.publish(&shared.b, &[]).await;
4088 shared.publish(&other, &[&shared.proc]).await;
4089
4090 let proc = shared.resolve_proc(&admin).await.unwrap();
4091 assert_eq!(proc.parent, host_ref(&shared.a));
4092 shared
4093 .resolve(
4094 &admin,
4095 crate::introspect::NodeRef::Host(other.actor_addr().clone()),
4096 )
4097 .await
4098 .unwrap();
4099 let proc = shared.resolve_proc(&admin).await.unwrap();
4100 assert_eq!(proc.parent, host_ref(&shared.a));
4101 }
4102
4103 #[tokio::test]
4106 async fn test_sole_host_owns_procs_at_its_address() {
4107 let shared = SharedAddress::new().await;
4108 let admin = shared.admin(&[&shared.a]).await;
4109 shared.publish(&shared.a, &[]).await;
4110
4111 let proc = shared.resolve_proc(&admin).await.unwrap();
4112 assert_eq!(proc.parent, host_ref(&shared.a));
4113 }
4114
4115 #[tokio::test]
4116 async fn test_listing_host_wins_over_silent_candidate() {
4117 let config = hyperactor_config::global::lock();
4118 let _timeout = config.override_key(
4119 crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT,
4120 Duration::from_secs(1),
4121 );
4122 let shared = SharedAddress::new().await;
4123 let silent = shared.silent();
4124 let admin = shared.admin(&[&silent, &shared.a]).await;
4125 shared.publish(&shared.a, &[&shared.proc]).await;
4126
4127 let proc = shared.resolve_proc(&admin).await.unwrap();
4128 assert_eq!(proc.parent, host_ref(&shared.a));
4129 }
4130
4131 #[tokio::test]
4132 async fn test_proc_no_candidate_lists_has_no_host() {
4133 let shared = SharedAddress::new().await;
4134 let admin = shared.admin(&[&shared.a, &shared.b]).await;
4135 shared.publish(&shared.a, &[]).await;
4136 shared.publish(&shared.b, &[]).await;
4137
4138 let err = shared.resolve_proc(&admin).await.unwrap_err();
4139 assert!(err.contains("no host found"), "{err}");
4140 }
4141
4142 #[tokio::test]
4145 async fn test_silent_candidate_leaves_owner_unknown() {
4146 let config = hyperactor_config::global::lock();
4147 let _timeout = config.override_key(
4148 crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT,
4149 Duration::from_secs(1),
4150 );
4151 let shared = SharedAddress::new().await;
4152 let silent = shared.silent();
4153 let admin = shared.admin(&[&silent, &shared.a]).await;
4154 shared.publish(&shared.a, &[]).await;
4155
4156 let err = shared.resolve_proc(&admin).await.unwrap_err();
4157 assert!(err.contains("cannot tell which host"), "{err}");
4158 }
4159
4160 #[tokio::test]
4161 async fn test_two_hosts_listing_a_proc_is_an_error() {
4162 let shared = SharedAddress::new().await;
4163 let admin = shared.admin(&[&shared.a, &shared.b]).await;
4164 shared.publish(&shared.a, &[&shared.proc]).await;
4165 shared.publish(&shared.b, &[&shared.proc]).await;
4166
4167 let err = shared.resolve_proc(&admin).await.unwrap_err();
4168 assert!(err.contains("claimed by"), "{err}");
4169 }
4170
4171 #[tokio::test]
4177 async fn test_resolve_reference_tree_walk() {
4178 use hyperactor::Proc;
4179 use hyperactor::channel::ChannelTransport;
4180
4181 use crate::host::Host;
4182 use crate::host::LocalProcManager;
4183 use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4184 use crate::proc_agent::ProcAgent;
4185
4186 let spawn: ProcManagerSpawnFn =
4190 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4191 let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4192 let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4193 Host::new(manager, ChannelTransport::Unix.any())
4194 .await
4195 .unwrap();
4196 let host_addr = host.addr().clone();
4197 let system_proc = host.system_proc().clone();
4198 let host_agent_handle = system_proc.spawn_with_label(
4199 crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4200 HostAgent::new_local(host),
4201 );
4202 HostAgent::wait_initialized(&host_agent_handle)
4203 .await
4204 .unwrap();
4205 let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4206 let host_addr_str = host_addr.to_string();
4207
4208 let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4213 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4216 let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4217 let admin_handle = admin_proc.spawn_with_label(
4218 MESH_ADMIN_ACTOR_NAME,
4219 MeshAdminAgent::new(
4220 vec![(host_addr_str.clone(), host_agent_ref.clone())],
4221 None,
4222 Some("[::]:0".parse().unwrap()),
4223 None,
4224 ),
4225 );
4226 let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4227
4228 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4232 let client = client_proc.client("client");
4233
4234 let root_resp = admin_ref
4236 .resolve(&client, "root".to_string())
4237 .await
4238 .unwrap();
4239 let root = root_resp.0.unwrap();
4240 assert_eq!(root.identity, crate::introspect::NodeRef::Root);
4241 assert!(matches!(
4242 root.properties,
4243 NodeProperties::Root { num_hosts: 1, .. }
4244 ));
4245 assert_eq!(root.parent, None);
4246 assert_eq!(root.children.len(), 1); let expected_host_ref =
4250 crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone());
4251 let host_child_ref = root
4252 .children
4253 .iter()
4254 .find(|c| **c == expected_host_ref)
4255 .expect("root children should contain the host agent (as Host ref)");
4256 let host_ref_string = host_child_ref.to_string();
4257 let host_resp = admin_ref.resolve(&client, host_ref_string).await.unwrap();
4258 let host_node = host_resp.0.unwrap();
4259 assert_eq!(host_node.identity, expected_host_ref);
4260 assert!(
4261 matches!(host_node.properties, NodeProperties::Host { .. }),
4262 "expected Host properties, got {:?}",
4263 host_node.properties
4264 );
4265 assert_eq!(host_node.parent, Some(crate::introspect::NodeRef::Root));
4266 assert!(
4267 !host_node.children.is_empty(),
4268 "host should have at least one proc child"
4269 );
4270 match &host_node.properties {
4272 NodeProperties::Host {
4273 system_children, ..
4274 } => {
4275 assert!(
4276 system_children.is_empty(),
4277 "LC-2: host system_children must be empty"
4278 );
4279 }
4280 other => panic!("expected Host, got {:?}", other),
4281 }
4282
4283 let proc_ref = &host_node.children[0];
4285 let proc_ref_str = proc_ref.to_string();
4286 let proc_resp = admin_ref.resolve(&client, proc_ref_str).await.unwrap();
4287 let proc_node = proc_resp.0.unwrap();
4288 assert!(
4289 matches!(proc_node.properties, NodeProperties::Proc { .. }),
4290 "expected Proc properties, got {:?}",
4291 proc_node.properties
4292 );
4293 assert_eq!(proc_node.parent, Some(expected_host_ref.clone()));
4294 assert!(
4296 !proc_node.children.is_empty(),
4297 "proc should have at least one actor child"
4298 );
4299
4300 let host_agent_node_ref =
4310 crate::introspect::NodeRef::Actor(host_agent_ref.actor_addr().clone());
4311 assert!(
4312 proc_node.children.contains(&host_agent_node_ref),
4313 "system proc children {:?} should contain the host agent {:?}",
4314 proc_node.children,
4315 host_agent_node_ref
4316 );
4317
4318 let xref_resp = admin_ref
4320 .resolve(&client, host_agent_ref.actor_addr().to_string())
4321 .await
4322 .unwrap();
4323 let xref_node = xref_resp.0.unwrap();
4324
4325 assert!(
4328 matches!(xref_node.properties, NodeProperties::Actor { .. }),
4329 "host agent child resolved as plain actor should be Actor, got {:?}",
4330 xref_node.properties
4331 );
4332 }
4333
4334 #[tokio::test]
4339 async fn test_proc_properties_for_all_procs() {
4340 use std::time::Duration;
4341
4342 use hyperactor::Proc;
4343 use hyperactor::channel::ChannelTransport;
4344 use hyperactor::id::Label;
4345
4346 use crate::host::Host;
4347 use crate::host::LocalProcManager;
4348 use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4349 use crate::proc_agent::ProcAgent;
4350 use crate::resource;
4351 use crate::resource::ProcSpec;
4352 use crate::resource::Rank;
4353
4354 let spawn: ProcManagerSpawnFn =
4356 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4357 let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4358 let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4359 Host::new(manager, ChannelTransport::Unix.any())
4360 .await
4361 .unwrap();
4362 let host_addr = host.addr().clone();
4363 let system_proc_id: ProcAddr = host.system_proc().proc_addr().clone();
4364 let local_proc_id: ProcAddr = host.local_proc().proc_addr().clone();
4365 let system_proc = host.system_proc().clone();
4366 let host_agent_handle = system_proc.spawn_with_label(
4367 crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4368 HostAgent::new_local(host),
4369 );
4370 HostAgent::wait_initialized(&host_agent_handle)
4371 .await
4372 .unwrap();
4373 let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4374 let host_addr_str = host_addr.to_string();
4375
4376 let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4380 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4381 let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4382 let admin_handle = admin_proc.spawn_with_label(
4383 MESH_ADMIN_ACTOR_NAME,
4384 MeshAdminAgent::new(
4385 vec![(host_addr_str.clone(), host_agent_ref.clone())],
4386 None,
4387 Some("[::]:0".parse().unwrap()),
4388 None,
4389 ),
4390 );
4391 let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4392
4393 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4395 let client = client_proc.client("client");
4396
4397 let user_proc_name = ResourceId::instance(Label::new("user-proc").unwrap());
4399 host_agent_ref.post(
4400 &client,
4401 resource::CreateOrUpdate {
4402 id: user_proc_name.clone(),
4403 rank: Rank::new(0),
4404 spec: ProcSpec::default(),
4405 },
4406 );
4407
4408 tokio::time::sleep(Duration::from_secs(2)).await;
4410
4411 let host_ref_string =
4413 crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone()).to_string();
4414 let host_resp = admin_ref.resolve(&client, host_ref_string).await.unwrap();
4415 let host_node = host_resp.0.unwrap();
4416
4417 assert!(
4420 host_node.children.len() >= 3,
4421 "expected at least 3 proc children (2 system + 1 user), got {}",
4422 host_node.children.len()
4423 );
4424
4425 let mut found_system = false;
4427 let mut found_user = false;
4428 for child_ref in &host_node.children {
4429 let resp = admin_ref
4430 .resolve(&client, child_ref.to_string())
4431 .await
4432 .unwrap();
4433 let node = resp.0.unwrap();
4434 if let NodeProperties::Proc { .. } = &node.properties {
4435 if matches!(
4436 child_ref,
4437 crate::introspect::NodeRef::Proc(proc_id)
4438 if *proc_id != system_proc_id && *proc_id != local_proc_id
4439 ) {
4440 found_user = true;
4441 } else {
4442 found_system = true;
4443 }
4444 } else {
4446 }
4448 }
4449 assert!(
4450 found_system,
4451 "should have resolved at least one system proc"
4452 );
4453 assert!(found_user, "should have resolved the user proc");
4454 }
4455
4456 #[test]
4460 fn test_build_root_payload_with_root_client() {
4461 let addr1: SocketAddr = "127.0.0.1:9001".parse().unwrap();
4462 let proc1 = ResourceId::proc_addr_from_name(ChannelAddr::Tcp(addr1), "host1");
4463 let actor_id1 = hyperactor::ActorAddr::root(proc1, Label::new("mesh_agent").unwrap());
4464 let ref1: ActorRef<HostAgent> = ActorRef::attest(actor_id1.clone());
4465
4466 let client_proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Tcp(addr1), "local");
4467 let client_actor_id = client_proc_id.actor_addr("client");
4468
4469 let agent = MeshAdminAgent::new(
4470 vec![("host_a".to_string(), ref1)],
4471 Some(client_actor_id.clone()),
4472 None,
4473 None,
4474 );
4475
4476 let payload = agent.build_root_payload();
4477 assert!(matches!(
4478 payload.properties,
4479 NodeProperties::Root { num_hosts: 1, .. }
4480 ));
4481 assert_eq!(payload.children.len(), 1);
4483 assert!(
4484 payload
4485 .children
4486 .contains(&crate::introspect::NodeRef::Host(actor_id1.clone()))
4487 );
4488 }
4489
4490 #[tokio::test]
4494 async fn test_resolve_root_client_actor() {
4495 use hyperactor::channel::ChannelTransport;
4496
4497 use crate::host::Host;
4498 use crate::host::LocalProcManager;
4499 use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4500 use crate::proc_agent::ProcAgent;
4501
4502 let spawn: ProcManagerSpawnFn =
4504 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4505 let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4506 let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4507 Host::new(manager, ChannelTransport::Unix.any())
4508 .await
4509 .unwrap();
4510 let host_addr = host.addr().clone();
4511 let system_proc = host.system_proc().clone();
4512
4513 let local_proc = host.local_proc();
4516 let local_proc_id = local_proc.proc_addr().clone();
4517 let root_client_handle = local_proc.spawn_with_label("client", TestIntrospectableActor);
4518 let root_client_ref: ActorRef<TestIntrospectableActor> = root_client_handle.bind();
4519 let root_client_actor_id = root_client_ref.actor_addr().clone();
4520
4521 let host_agent_handle = system_proc.spawn_with_label(
4522 crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4523 HostAgent::new_local(host),
4524 );
4525 HostAgent::wait_initialized(&host_agent_handle)
4526 .await
4527 .unwrap();
4528 let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4529 let host_addr_str = host_addr.to_string();
4530
4531 let admin_proc =
4536 hyperactor::Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4537 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4538 let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4539 let admin_handle = admin_proc.spawn_with_label(
4540 MESH_ADMIN_ACTOR_NAME,
4541 MeshAdminAgent::new(
4542 vec![(host_addr_str.clone(), host_agent_ref.clone())],
4543 Some(root_client_actor_id.clone()),
4544 Some("[::]:0".parse().unwrap()),
4545 None,
4546 ),
4547 );
4548 let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4549
4550 let client_proc =
4552 hyperactor::Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4553 let client = client_proc.client("client");
4554
4555 let root_resp = admin_ref
4557 .resolve(&client, "root".to_string())
4558 .await
4559 .unwrap();
4560 let root = root_resp.0.unwrap();
4561 let host_node_ref = crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone());
4562 assert!(
4563 root.children.contains(&host_node_ref),
4564 "root children {:?} should contain host {:?}",
4565 root.children,
4566 host_node_ref
4567 );
4568
4569 let host_resp = admin_ref
4571 .resolve(&client, host_node_ref.to_string())
4572 .await
4573 .unwrap();
4574 let host_node = host_resp.0.unwrap();
4575 let local_proc_node_ref = crate::introspect::NodeRef::Proc(local_proc_id.clone());
4576 assert!(
4577 host_node.children.contains(&local_proc_node_ref),
4578 "host children {:?} should contain local proc {:?}",
4579 host_node.children,
4580 local_proc_node_ref
4581 );
4582
4583 let proc_resp = admin_ref
4585 .resolve(&client, local_proc_id.to_string())
4586 .await
4587 .unwrap();
4588 let proc_node = proc_resp.0.unwrap();
4589 assert!(
4590 matches!(proc_node.properties, NodeProperties::Proc { .. }),
4591 "expected Proc properties, got {:?}",
4592 proc_node.properties
4593 );
4594 let root_client_node_ref = crate::introspect::NodeRef::Actor(root_client_actor_id.clone());
4595 assert!(
4596 proc_node.children.contains(&root_client_node_ref),
4597 "local proc children {:?} should contain root client actor {:?}",
4598 proc_node.children,
4599 root_client_node_ref
4600 );
4601
4602 let client_resp = admin_ref
4604 .resolve(&client, root_client_actor_id.to_string())
4605 .await
4606 .unwrap();
4607 let client_node = client_resp.0.unwrap();
4608 assert!(
4609 matches!(client_node.properties, NodeProperties::Actor { .. }),
4610 "expected Actor properties, got {:?}",
4611 client_node.properties
4612 );
4613 assert_eq!(
4614 client_node.parent,
4615 Some(local_proc_node_ref),
4616 "root client parent should be the local proc"
4617 );
4618 }
4619
4620 #[test]
4624 fn test_skill_md_contains_canonical_strings() {
4625 let template = SKILL_MD_TEMPLATE;
4626 assert!(
4627 template.contains("GET {base}/v1/root"),
4628 "SKILL.md must document the root endpoint"
4629 );
4630 assert!(
4631 template.contains("GET {base}/v1/{reference}"),
4632 "SKILL.md must document the reference endpoint"
4633 );
4634 assert!(
4635 template.contains("NodePayload"),
4636 "SKILL.md must mention the NodePayload response type"
4637 );
4638 assert!(
4639 template.contains("GET {base}/SKILL.md"),
4640 "SKILL.md must document itself"
4641 );
4642 assert!(
4643 template.contains("{base}"),
4644 "SKILL.md must use {{base}} placeholder for interpolation"
4645 );
4646 }
4647
4648 #[tokio::test]
4657 async fn test_navigation_identity_invariant() {
4658 use hyperactor::Proc;
4659 use hyperactor::channel::ChannelTransport;
4660
4661 use crate::host::Host;
4662 use crate::host::LocalProcManager;
4663 use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4664 use crate::proc_agent::ProcAgent;
4665
4666 let spawn: ProcManagerSpawnFn =
4668 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4669 let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4670 let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4671 Host::new(manager, ChannelTransport::Unix.any())
4672 .await
4673 .unwrap();
4674 let host_addr = host.addr().clone();
4675 let system_proc = host.system_proc().clone();
4676 let host_agent_handle = system_proc.spawn_with_label(
4677 crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4678 HostAgent::new_local(host),
4679 );
4680 HostAgent::wait_initialized(&host_agent_handle)
4681 .await
4682 .unwrap();
4683 let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4684 let host_addr_str = host_addr.to_string();
4685
4686 let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4690 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4691 let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4692 let admin_handle = admin_proc.spawn_with_label(
4693 MESH_ADMIN_ACTOR_NAME,
4694 MeshAdminAgent::new(
4695 vec![(host_addr_str, host_agent_ref)],
4696 None,
4697 Some("[::]:0".parse().unwrap()),
4698 None,
4699 ),
4700 );
4701 let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4702
4703 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4704 let client = client_proc.client("client");
4705
4706 let mut queue: std::collections::VecDeque<(String, Option<crate::introspect::NodeRef>)> =
4709 std::collections::VecDeque::new();
4710 queue.push_back(("root".to_string(), None));
4711
4712 let mut visited = std::collections::HashSet::new();
4713 while let Some((ref_str, expected_parent)) = queue.pop_front() {
4714 if !visited.insert(ref_str.clone()) {
4715 continue;
4716 }
4717
4718 let resp = admin_ref.resolve(&client, ref_str.clone()).await.unwrap();
4719 let node = resp.0.unwrap();
4720
4721 assert_eq!(
4723 node.identity.to_string(),
4724 ref_str,
4725 "identity mismatch: resolved '{}' but payload.identity = '{}'",
4726 ref_str,
4727 node.identity
4728 );
4729
4730 assert_eq!(
4732 node.parent, expected_parent,
4733 "parent mismatch for '{}': expected {:?}, got {:?}",
4734 ref_str, expected_parent, node.parent
4735 );
4736
4737 for child_ref in &node.children {
4740 let child_str = child_ref.to_string();
4741 if !visited.contains(&child_str) {
4742 queue.push_back((child_str, Some(node.identity.clone())));
4743 }
4744 }
4745 }
4746
4747 assert!(
4750 visited.len() >= 4,
4751 "expected at least 4 nodes in the tree, visited {}",
4752 visited.len()
4753 );
4754 }
4755
4756 #[tokio::test]
4758 async fn test_system_proc_identity() {
4759 use hyperactor::Proc;
4760 use hyperactor::channel::ChannelTransport;
4761
4762 use crate::host::Host;
4763 use crate::host::LocalProcManager;
4764 use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4765 use crate::proc_agent::ProcAgent;
4766
4767 let spawn: ProcManagerSpawnFn =
4769 Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4770 let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4771 let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4772 Host::new(manager, ChannelTransport::Unix.any())
4773 .await
4774 .unwrap();
4775 let host_addr = host.addr().clone();
4776 let system_proc = host.system_proc().clone();
4777 let system_proc_id = system_proc.proc_addr().clone();
4778 let host_agent_handle = system_proc.spawn_with_label(
4779 crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4780 HostAgent::new_local(host),
4781 );
4782 HostAgent::wait_initialized(&host_agent_handle)
4783 .await
4784 .unwrap();
4785 let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4786 let host_addr_str = host_addr.to_string();
4787
4788 let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4793 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4794 let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4795 let admin_handle = admin_proc.spawn_with_label(
4796 MESH_ADMIN_ACTOR_NAME,
4797 MeshAdminAgent::new(
4798 vec![(host_addr_str.clone(), host_agent_ref.clone())],
4799 None,
4800 Some("[::]:0".parse().unwrap()),
4801 None,
4802 ),
4803 );
4804 let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4805
4806 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4808 let client = client_proc.client("client");
4809
4810 let host_ref_str =
4812 crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone()).to_string();
4813 let host_resp = admin_ref
4814 .resolve(&client, host_ref_str.clone())
4815 .await
4816 .unwrap();
4817 let host_node = host_resp.0.unwrap();
4818 assert!(
4819 !host_node.children.is_empty(),
4820 "host should have at least one proc child"
4821 );
4822
4823 let system_children = match &host_node.properties {
4825 NodeProperties::Host {
4826 system_children, ..
4827 } => system_children.clone(),
4828 other => panic!("expected Host properties, got {:?}", other),
4829 };
4830 assert!(
4832 system_children.is_empty(),
4833 "host system_children should be empty (procs are never system), got {:?}",
4834 system_children
4835 );
4836 assert!(
4838 matches!(&host_node.properties, NodeProperties::Host { .. }),
4839 "expected Host properties"
4840 );
4841
4842 let expected_system_ref = crate::introspect::NodeRef::Proc(system_proc_id.clone());
4844 assert!(
4845 host_node.children.contains(&expected_system_ref),
4846 "host children {:?} should contain the system proc ref {:?}",
4847 host_node.children,
4848 expected_system_ref
4849 );
4850
4851 let proc_child_ref = &host_node.children[0];
4853 let proc_resp = admin_ref
4854 .resolve(&client, proc_child_ref.to_string())
4855 .await
4856 .unwrap();
4857 let proc_node = proc_resp.0.unwrap();
4858
4859 assert_eq!(
4860 proc_node.identity, *proc_child_ref,
4861 "identity must match the proc ref from the host's children list"
4862 );
4863
4864 assert!(
4865 matches!(proc_node.properties, NodeProperties::Proc { .. }),
4866 "expected NodeProperties::Proc, got {:?}",
4867 proc_node.properties
4868 );
4869
4870 let host_node_ref = crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone());
4871 assert_eq!(
4872 proc_node.parent,
4873 Some(host_node_ref),
4874 "proc parent should be the host reference"
4875 );
4876
4877 assert!(
4879 proc_node.as_of > std::time::UNIX_EPOCH,
4880 "as_of should be after the epoch"
4881 );
4882
4883 assert!(
4885 matches!(&proc_node.properties, NodeProperties::Proc { .. }),
4886 "expected Proc properties"
4887 );
4888 }
4889
4890 #[test]
4894 fn test_admin_handle_parse_https_url() {
4895 let h = super::AdminHandle::parse("https://myhost:1729");
4896 assert!(matches!(h, super::AdminHandle::Url(u) if u == "https://myhost:1729"));
4897 }
4898
4899 #[test]
4900 fn test_admin_handle_parse_bare_host_port() {
4901 let h = super::AdminHandle::parse("myhost:1729");
4903 assert!(
4904 matches!(h, super::AdminHandle::Url(ref u) if u == "https://myhost:1729"),
4905 "bare host:port should become https://host:port, got: {:?}",
4906 matches!(h, super::AdminHandle::Url(_))
4907 );
4908 }
4909
4910 #[test]
4911 fn test_admin_handle_parse_mast() {
4912 let h = super::AdminHandle::parse("mast_conda:///my-job");
4913 assert!(matches!(
4914 h,
4915 super::AdminHandle::Published(super::PublishedHandle::Mast(_))
4916 ));
4917 }
4918
4919 #[test]
4920 fn test_admin_handle_parse_unsupported() {
4921 let h = super::AdminHandle::parse("junk_hostname_no_port");
4923 assert!(matches!(h, super::AdminHandle::Unsupported(_)));
4924 }
4925
4926 #[tokio::test]
4927 async fn test_admin_handle_resolve_url_returns_url() {
4928 let h = super::AdminHandle::parse("https://myhost:1729");
4929 let result = h.resolve(None).await.unwrap();
4930 assert_eq!(result, "https://myhost:1729");
4931 }
4932
4933 #[tokio::test]
4934 async fn test_admin_handle_resolve_published_returns_error() {
4935 let h = super::AdminHandle::parse("mast_conda:///test-job");
4936 let err = format!("{:#}", h.resolve(Some(1729)).await.unwrap_err());
4937 assert!(
4938 err.contains("not yet implemented"),
4939 "expected 'not yet implemented' in error, got: {}",
4940 err
4941 );
4942 }
4943
4944 #[tokio::test]
4945 async fn test_admin_handle_resolve_unsupported_returns_error() {
4946 let h = super::AdminHandle::parse("junk_hostname_no_port");
4947 let err = format!("{:#}", h.resolve(None).await.unwrap_err());
4948 assert!(
4949 err.contains("unrecognized admin handle"),
4950 "expected 'unrecognized admin handle' in error, got: {}",
4951 err
4952 );
4953 }
4954
4955 #[tokio::test]
4958 async fn test_resolve_mast_handle_returns_not_yet_implemented_error() {
4959 let result = super::resolve_mast_handle("mast_conda:///test-job", Some(1729)).await;
4960 let err = format!("{:#}", result.unwrap_err());
4961 assert!(
4962 err.contains("not yet implemented"),
4963 "expected 'not yet implemented' in error, got: {}",
4964 err
4965 );
4966 }
4967
4968 #[test]
4972 fn test_admin_info_new_derives_host_from_url() {
4973 let info = super::AdminInfo::new(
4974 "actor".to_string(),
4975 "proc".to_string(),
4976 "https://myhost.example.com:1729".to_string(),
4977 )
4978 .unwrap();
4979 assert_eq!(info.host, "myhost.example.com");
4980 assert_eq!(info.url, "https://myhost.example.com:1729");
4981 }
4982
4983 #[test]
4985 fn test_admin_info_new_rejects_invalid_url() {
4986 let result = super::AdminInfo::new(
4987 "actor".to_string(),
4988 "proc".to_string(),
4989 "not a url".to_string(),
4990 );
4991 assert!(result.is_err(), "invalid URL must be rejected");
4992 }
4993
4994 #[test]
4996 fn test_admin_info_new_rejects_url_without_host() {
4997 let result = super::AdminInfo::new(
4999 "actor".to_string(),
5000 "proc".to_string(),
5001 "data:text/plain,hello".to_string(),
5002 );
5003 assert!(result.is_err(), "URL without host must be rejected");
5004 }
5005
5006 #[tokio::test]
5011 async fn test_spawn_admin_places_on_caller_proc() {
5012 use hyperactor::Proc;
5013 use hyperactor::channel::ChannelTransport;
5014 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
5015
5016 use crate::host_mesh::HostMesh;
5017
5018 let host_mesh = HostMesh::local().await.unwrap();
5020
5021 let caller_proc = Proc::direct(ChannelTransport::Unix.any(), "caller".to_string()).unwrap();
5023 let _supervision = ProcSupervisionCoordinator::set(&caller_proc).await.unwrap();
5024 let caller_cx = caller_proc.client("caller");
5025
5026 let admin_ref = crate::host_mesh::spawn_admin(
5028 [&host_mesh],
5029 &caller_cx,
5030 Some("[::]:0".parse().unwrap()),
5031 None,
5032 )
5033 .await
5034 .unwrap();
5035
5036 let admin_url = admin_ref
5040 .get_admin_addr(&caller_cx)
5041 .await
5042 .unwrap()
5043 .addr
5044 .expect("SA-5: admin must report an address");
5045 assert!(
5046 !admin_url.is_empty(),
5047 "spawn_admin ref must yield a non-empty URL"
5048 );
5049 }
5050
5051 #[tokio::test]
5069 async fn test_proc_children_reflect_directly_spawned_actors() {
5070 use hyperactor::Proc;
5071 use hyperactor::actor::ActorStatus;
5072 use hyperactor::channel::ChannelTransport;
5073 use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
5074
5075 use crate::host_mesh::host_agent::HostAgent;
5076 use crate::proc_agent::PROC_AGENT_ACTOR_NAME;
5077 use crate::proc_agent::ProcAgent;
5078
5079 let user_proc =
5081 Proc::direct(ChannelTransport::Unix.any(), "user_proc".to_string()).unwrap();
5082 let agent_handle = ProcAgent::boot_v1(user_proc.clone(), None).unwrap();
5083 agent_handle
5084 .status()
5085 .wait_for(|s| matches!(s, ActorStatus::Idle))
5086 .await
5087 .unwrap();
5088
5089 let stand_in: ActorRef<HostAgent> = ActorRef::attest(
5096 user_proc
5097 .spawn_with_label("stand_in_host", TestIntrospectableActor)
5098 .bind::<TestIntrospectableActor>()
5099 .actor_addr()
5100 .clone(),
5101 );
5102 let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
5103 let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
5104 let admin_handle = admin_proc.spawn_with_label(
5105 MESH_ADMIN_ACTOR_NAME,
5106 MeshAdminAgent::new(
5107 vec![(String::new(), stand_in)],
5108 None,
5109 Some("[::]:0".parse().unwrap()),
5110 None,
5111 ),
5112 );
5113 let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
5114
5115 let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
5116 let client = client_proc.client("client");
5117
5118 let user_proc_ref = user_proc.proc_addr().to_string();
5122 let resp = admin_ref
5123 .resolve(&client, user_proc_ref.clone())
5124 .await
5125 .unwrap();
5126 let node = resp.0.unwrap();
5127 assert!(
5128 matches!(node.properties, NodeProperties::Proc { .. }),
5129 "expected Proc, got {:?}",
5130 node.properties
5131 );
5132 let initial_count = node.children.len();
5133 assert!(
5134 node.children
5135 .iter()
5136 .any(|c| c.to_string().contains(PROC_AGENT_ACTOR_NAME)),
5137 "initial children {:?} should contain proc_agent",
5138 node.children
5139 );
5140
5141 user_proc.spawn_with_label("extra_actor", TestIntrospectableActor);
5144
5145 let resp2 = admin_ref
5148 .resolve(&client, user_proc_ref.clone())
5149 .await
5150 .unwrap();
5151 let node2 = resp2.0.unwrap();
5152 assert!(
5153 matches!(node2.properties, NodeProperties::Proc { .. }),
5154 "expected Proc, got {:?}",
5155 node2.properties
5156 );
5157 assert!(
5158 node2
5159 .children
5160 .iter()
5161 .any(|c| c.to_string().contains("extra_actor")),
5162 "after direct spawn, children {:?} should contain extra_actor",
5163 node2.children
5164 );
5165 assert!(
5166 node2.children.len() > initial_count,
5167 "expected at least {} children after direct spawn, got {:?}",
5168 initial_count + 1,
5169 node2.children
5170 );
5171 }
5172
5173 #[test]
5180 fn pyspy_parse_empty_reference() {
5181 let err = parse_proc_reference("").unwrap_err();
5183 assert_eq!(err.code, "bad_request");
5184 assert!(err.message.contains("empty"));
5185 }
5186
5187 #[test]
5188 fn pyspy_parse_slash_only() {
5189 let err = parse_proc_reference("/").unwrap_err();
5191 assert_eq!(err.code, "bad_request");
5192 assert!(err.message.contains("empty"));
5193 }
5194
5195 #[test]
5196 fn pyspy_parse_malformed_percent_encoding() {
5197 let err = parse_proc_reference("%FF%FE").unwrap_err();
5200 assert_eq!(err.code, "bad_request");
5201 assert!(err.message.contains("percent-encoding"));
5202 }
5203
5204 #[test]
5205 fn pyspy_parse_invalid_proc_id() {
5206 let err = parse_proc_reference("not-a-valid-proc-id").unwrap_err();
5208 assert_eq!(err.code, "bad_request");
5209 assert!(err.message.contains("invalid proc reference"));
5210 }
5211
5212 #[test]
5213 fn pyspy_parse_valid_proc_reference() {
5214 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5216 let proc_id = test_proc_id_with_addr(ChannelAddr::Tcp(addr), "myproc");
5217 let proc_id_str = proc_id.to_string();
5218
5219 let (decoded, parsed) = parse_proc_reference(&proc_id_str).unwrap();
5220 assert_eq!(decoded, proc_id_str);
5221 assert_eq!(parsed, proc_id);
5222 }
5223
5224 #[test]
5225 fn pyspy_parse_strips_leading_slash() {
5226 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5228 let proc_id = test_proc_id_with_addr(ChannelAddr::Tcp(addr), "myproc");
5229 let with_slash = format!("/{}", proc_id);
5230
5231 let (_, parsed) = parse_proc_reference(&with_slash).unwrap();
5232 assert_eq!(parsed, proc_id);
5233 }
5234
5235 #[test]
5237 fn route_proc_handler_service_proc_yields_host() {
5238 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5239 let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Tcp(addr), SERVICE_PROC_NAME);
5240 let host_agents = HashSet::from([HostAgentKey::new(
5241 &proc_id.actor_addr(HOST_MESH_AGENT_ACTOR_NAME),
5242 )]);
5243 let handler = route_proc_handler(&host_agents, &proc_id.to_string()).unwrap();
5244 assert!(
5245 matches!(handler, ResolvedProcHandler::Host(_)),
5246 "a registered legacy service proc should resolve to Host"
5247 );
5248 }
5249
5250 #[test]
5252 fn route_proc_handler_worker_proc_yields_proc() {
5253 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5254 let proc_id = test_proc_id_with_addr(ChannelAddr::Tcp(addr), "worker_0");
5255 let handler = route_proc_handler(&HashSet::new(), &proc_id.to_string()).unwrap();
5256 assert!(
5257 matches!(handler, ResolvedProcHandler::Proc(_)),
5258 "an unregistered worker proc should resolve to Proc"
5259 );
5260 }
5261
5262 #[test]
5264 fn route_proc_handler_registered_instance_yields_host() {
5265 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5266 let proc_id = ProcAddr::new(
5267 ProcId::instance(Label::strip("host-control")),
5268 ChannelAddr::Tcp(addr).into(),
5269 );
5270 let host_agents = HashSet::from([HostAgentKey::new(
5271 &proc_id.actor_addr(HOST_MESH_AGENT_ACTOR_NAME),
5272 )]);
5273 let handler = route_proc_handler(&host_agents, &proc_id.to_string()).unwrap();
5274 assert!(
5275 matches!(handler, ResolvedProcHandler::Host(_)),
5276 "a registered proc should resolve to Host regardless of its label"
5277 );
5278 }
5279
5280 #[test]
5281 fn route_proc_handler_service_label_without_host_registration_yields_proc() {
5282 let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5283 let proc_id = ProcAddr::new(
5284 ProcId::instance(Label::strip(SERVICE_PROC_NAME)),
5285 ChannelAddr::Tcp(addr).into(),
5286 );
5287 let handler = route_proc_handler(&HashSet::new(), &proc_id.to_string()).unwrap();
5288 assert!(
5289 matches!(handler, ResolvedProcHandler::Proc(_)),
5290 "an unregistered proc must not become a host service based on its label"
5291 );
5292 }
5293}