doctor.rs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. use anyhow::{Context, Result};
  2. use tracing::info;
  3. use crate::config::*;
  4. use crate::ssh::*;
  5. /// Doctor check result for a single node/container.
  6. pub struct DoctorResult {
  7. pub node_name: String,
  8. pub checks: Vec<DoctorCheck>,
  9. }
  10. pub(crate) struct DoctorCheck {
  11. name: String,
  12. status: DoctorStatus,
  13. detail: String,
  14. }
  15. #[derive(PartialEq)]
  16. enum DoctorStatus {
  17. Ok,
  18. Warn,
  19. Fail,
  20. }
  21. impl std::fmt::Display for DoctorStatus {
  22. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  23. match self {
  24. DoctorStatus::Ok => write!(f, "✓"),
  25. DoctorStatus::Warn => write!(f, "⚠"),
  26. DoctorStatus::Fail => write!(f, "✗"),
  27. }
  28. }
  29. }
  30. /// Run doctor checks on the cluster for a recipe.
  31. pub async fn doctor(
  32. config: &Config,
  33. recipe_name: &str,
  34. cluster_name: &str,
  35. repair: bool,
  36. dry_run: bool,
  37. ) -> Result<()> {
  38. let recipe = load_recipe(recipe_name).await?;
  39. println!(
  40. "\n🩺 Doctor: {} ({}) [backend: {}]",
  41. recipe.name, recipe.engine, recipe.distributed_backend
  42. );
  43. let (cluster, _nccl) = resolve_cluster(config, &recipe, cluster_name)?;
  44. // Check each node
  45. let mut all_results: Vec<DoctorResult> = Vec::new();
  46. let mut handles = Vec::new();
  47. for node in &cluster.nodes {
  48. let config = config.clone();
  49. let node = node.clone();
  50. let engine = recipe.engine.clone();
  51. let backend = recipe.distributed_backend.clone();
  52. let image = recipe.docker_image.clone();
  53. handles.push(tokio::spawn(async move {
  54. check_node(&config, &node, &engine, &backend, &image).await
  55. }));
  56. }
  57. let results = futures::future::join_all(handles).await;
  58. for r in results.into_iter().flatten() {
  59. all_results.push(r);
  60. }
  61. // Print results
  62. let mut has_failures = false;
  63. for result in &all_results {
  64. println!("\n--- {} ---", result.node_name);
  65. for check in &result.checks {
  66. println!(" {} {} {}", check.status, check.name, check.detail);
  67. if check.status == DoctorStatus::Fail {
  68. has_failures = true;
  69. }
  70. }
  71. }
  72. if has_failures {
  73. if repair {
  74. println!("\n🔧 Repairing image...");
  75. let repaired = run_cluster_repair(config, &cluster, &recipe, dry_run).await?;
  76. println!("✅ Repaired image: {}", repaired);
  77. } else {
  78. println!("\n⚠ Some checks failed. Run with --repair to auto-fix.");
  79. std::process::exit(1);
  80. }
  81. } else {
  82. println!("\n✅ All checks passed!");
  83. }
  84. Ok(())
  85. }
  86. /// Check a single node for required software.
  87. async fn check_node(
  88. config: &Config,
  89. node: &Node,
  90. engine: &str,
  91. backend: &str,
  92. image: &str,
  93. ) -> DoctorResult {
  94. let mut checks = Vec::new();
  95. checks.push(node_check(config, node, "Docker", "docker --version").await);
  96. checks.push(node_check(config, node, "NVIDIA GPU", "nvidia-smi").await);
  97. checks.push(
  98. node_check(
  99. config,
  100. node,
  101. "NCCL libs",
  102. "ldconfig -p | grep -q nccl && echo OK",
  103. )
  104. .await,
  105. );
  106. checks
  107. .push(container_check(config, node, image, "Engine", format!("{} version", engine)).await);
  108. if backend == "ray" {
  109. checks.push(container_check(config, node, image, "Ray", "ray --version".to_string()).await);
  110. }
  111. checks.push(container_entrypoint_check(config, node, image).await);
  112. DoctorResult {
  113. node_name: node.name.clone(),
  114. checks,
  115. }
  116. }
  117. /// Run a command on the node via SSH and return a check result.
  118. async fn node_check(config: &Config, node: &Node, name: &str, command: &str) -> DoctorCheck {
  119. match run_ssh(config, node, command.to_string(), false).await {
  120. Ok(output) if !output.trim().is_empty() => DoctorCheck {
  121. name: name.to_string(),
  122. status: DoctorStatus::Ok,
  123. detail: format!(
  124. "OK: {}",
  125. output.trim().lines().next().unwrap_or(output.trim())
  126. ),
  127. },
  128. Ok(_) => DoctorCheck {
  129. name: name.to_string(),
  130. status: DoctorStatus::Fail,
  131. detail: "not found".to_string(),
  132. },
  133. Err(e) => DoctorCheck {
  134. name: name.to_string(),
  135. status: DoctorStatus::Fail,
  136. detail: format!("error: {}", e),
  137. },
  138. }
  139. }
  140. /// Run a command inside a container on the node and return a check result.
  141. async fn container_check(
  142. config: &Config,
  143. node: &Node,
  144. image: &str,
  145. name: &str,
  146. command: String,
  147. ) -> DoctorCheck {
  148. let docker_cmd = format!("docker run --rm {} {} 2>&1 | head -1", image, command);
  149. match run_ssh(config, node, docker_cmd, false).await {
  150. Ok(output) if !output.contains("not found") && !output.contains("No such file") => {
  151. DoctorCheck {
  152. name: name.to_string(),
  153. status: DoctorStatus::Ok,
  154. detail: format!("OK: {}", output.trim()),
  155. }
  156. }
  157. Ok(output) => DoctorCheck {
  158. name: name.to_string(),
  159. status: DoctorStatus::Fail,
  160. detail: format!("not found: {}", output.trim()),
  161. },
  162. Err(e) => DoctorCheck {
  163. name: name.to_string(),
  164. status: DoctorStatus::Fail,
  165. detail: format!("error: {}", e),
  166. },
  167. }
  168. }
  169. /// Check the container entrypoint.
  170. async fn container_entrypoint_check(config: &Config, node: &Node, image: &str) -> DoctorCheck {
  171. let docker_cmd = format!(
  172. "docker inspect --format '{{{{json .Config.Entrypoint}}}}' {} 2>/dev/null || echo 'null'",
  173. image
  174. );
  175. match run_ssh(config, node, docker_cmd, false).await {
  176. Ok(output) => {
  177. let entrypoint = output.trim();
  178. if entrypoint == "null" || entrypoint == "[]" {
  179. DoctorCheck {
  180. name: "Entrypoint".to_string(),
  181. status: DoctorStatus::Ok,
  182. detail: "default (shell) entrypoint".to_string(),
  183. }
  184. } else {
  185. DoctorCheck {
  186. name: "Entrypoint".to_string(),
  187. status: DoctorStatus::Warn,
  188. detail: format!("custom: {}", entrypoint),
  189. }
  190. }
  191. }
  192. Err(e) => DoctorCheck {
  193. name: "Entrypoint".to_string(),
  194. status: DoctorStatus::Fail,
  195. detail: format!("error: {}", e),
  196. },
  197. }
  198. }
  199. /// Generate Dockerfile and build repaired image on head node.
  200. async fn run_cluster_repair(
  201. config: &Config,
  202. cluster: &Cluster,
  203. recipe: &Recipe,
  204. dry_run: bool,
  205. ) -> Result<String> {
  206. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  207. let repaired_tag = format!("{}.repaired", recipe.docker_image);
  208. let dockerfile = generate_repair_dockerfile(&repaired_tag, recipe);
  209. let dockerfile_path = format!("recipes/{}/Dockerfile.repaired", "temp");
  210. tokio::fs::write(&dockerfile_path, &dockerfile)
  211. .await
  212. .context("Failed to write Dockerfile")?;
  213. let create_dockerfile = format!(
  214. "mkdir -p /tmp/sm121_docker && cat > /tmp/sm121_docker/Dockerfile << 'DOCKERFILE_END'\n{}DOCKERFILE_END",
  215. dockerfile
  216. );
  217. if !dry_run {
  218. run_ssh(config, head_node, create_dockerfile, false).await?;
  219. let build_cmd = format!("cd /tmp/sm121_docker && docker build -t {} .", repaired_tag);
  220. let output = run_ssh_with_retry(config, head_node, build_cmd, false, 1)
  221. .await
  222. .context("Failed to build repaired image")?;
  223. println!("Build output: {}", output.lines().last().unwrap_or(&output));
  224. } else {
  225. println!(
  226. " (dry-run) Would build {} on {}",
  227. repaired_tag, head_node.name
  228. );
  229. }
  230. tokio::fs::remove_file(&dockerfile_path).await.ok();
  231. Ok(repaired_tag)
  232. }
  233. /// Generate a Dockerfile to repair a container.
  234. fn generate_repair_dockerfile(base_image: &str, recipe: &Recipe) -> String {
  235. let mut lines = vec![format!("FROM {}", base_image)];
  236. if recipe.distributed_backend == "ray" {
  237. lines.push("RUN pip install ray[default] || pip install ray".to_string());
  238. }
  239. let engine_install = match recipe.engine.as_str() {
  240. "vllm" => "pip install vllm",
  241. "sglang" => "pip install sglang",
  242. _ => "",
  243. };
  244. if !engine_install.is_empty() {
  245. lines.push(format!("RUN {} || true", engine_install));
  246. }
  247. lines.push("ENTRYPOINT [\"/bin/bash\", \"-c\"]".to_string());
  248. lines.join("\n")
  249. }
  250. /// Check if a repaired image exists on the head node.
  251. pub async fn try_repaired_image(
  252. config: &Config,
  253. cluster: &Cluster,
  254. recipe: &Recipe,
  255. ) -> Result<String> {
  256. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  257. let repaired_tag = format!("{}.repaired", recipe.docker_image);
  258. let check_cmd = format!("docker images -q {} | head -1", repaired_tag);
  259. match run_ssh(config, head_node, check_cmd, false).await {
  260. Ok(output) if !output.trim().is_empty() => {
  261. info!("🩺 Found repaired image: {}", repaired_tag);
  262. Ok(repaired_tag)
  263. }
  264. _ => Ok(recipe.docker_image.clone()),
  265. }
  266. }
  267. // ====================== Tests ======================
  268. #[cfg(test)]
  269. mod tests {
  270. use super::*;
  271. use std::collections::HashMap;
  272. fn test_recipe() -> Recipe {
  273. Recipe {
  274. engine: "vllm".to_string(),
  275. name: "Test Model".to_string(),
  276. docker_image: "test/image:latest".to_string(),
  277. model: "test/model".to_string(),
  278. port: 8000,
  279. required_nodes: Some(2),
  280. pipeline_parallel_size: Some(2),
  281. tensor_parallel_size: 1,
  282. args: vec!["--max-model-len".to_string(), "4096".to_string()],
  283. env: HashMap::new(),
  284. cluster: None,
  285. entrypoint: vec![],
  286. distributed_backend: "mp".to_string(),
  287. before_serve: vec![],
  288. }
  289. }
  290. #[test]
  291. fn test_generate_repair_dockerfile_vllm() {
  292. let recipe = test_recipe();
  293. let dockerfile = generate_repair_dockerfile("base/image:tag", &recipe);
  294. assert!(dockerfile.contains("FROM base/image:tag"));
  295. assert!(dockerfile.contains("pip install vllm"));
  296. assert!(!dockerfile.contains("ray"));
  297. assert!(dockerfile.contains("ENTRYPOINT"));
  298. }
  299. #[test]
  300. fn test_generate_repair_dockerfile_ray() {
  301. let mut recipe = test_recipe();
  302. recipe.distributed_backend = "ray".to_string();
  303. let dockerfile = generate_repair_dockerfile("base/image:tag", &recipe);
  304. assert!(dockerfile.contains("FROM base/image:tag"));
  305. assert!(dockerfile.contains("pip install vllm"));
  306. assert!(dockerfile.contains("pip install ray"));
  307. }
  308. #[test]
  309. fn test_generate_repair_dockerfile_sglang() {
  310. let mut recipe = test_recipe();
  311. recipe.engine = "sglang".to_string();
  312. let dockerfile = generate_repair_dockerfile("base/image:tag", &recipe);
  313. assert!(dockerfile.contains("pip install sglang"));
  314. assert!(!dockerfile.contains("vllm"));
  315. }
  316. }