doctor.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. use anyhow::{Context, Result};
  2. use std::collections::HashSet;
  3. use tracing::info;
  4. use crate::config::{Cluster, Config, Node, Recipe, load_recipe, resolve_cluster};
  5. use crate::ssh::{run_ssh, run_ssh_with_retry};
  6. /// Doctor check result for a single node/container.
  7. pub struct DoctorResult {
  8. pub node_name: String,
  9. pub checks: Vec<DoctorCheck>,
  10. }
  11. pub(crate) struct DoctorCheck {
  12. name: String,
  13. status: DoctorStatus,
  14. detail: String,
  15. }
  16. #[derive(PartialEq)]
  17. enum DoctorStatus {
  18. Ok,
  19. Warn,
  20. Fail,
  21. }
  22. impl std::fmt::Display for DoctorStatus {
  23. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  24. match self {
  25. DoctorStatus::Ok => write!(f, "✓"),
  26. DoctorStatus::Warn => write!(f, "⚠"),
  27. DoctorStatus::Fail => write!(f, "✗"),
  28. }
  29. }
  30. }
  31. /// Run doctor checks on the cluster for a recipe.
  32. pub async fn doctor(
  33. config: &Config,
  34. recipe_name: &str,
  35. cluster_name: &str,
  36. repair: bool,
  37. dry_run: bool,
  38. ) -> Result<()> {
  39. let recipe = load_recipe(recipe_name, &config.recipe_sources).await?;
  40. let dn = recipe.name.clone();
  41. let de = recipe.engine.clone();
  42. let db = recipe
  43. .distributed_backend
  44. .map_or("none".to_string(), |b| b.to_string());
  45. println!(
  46. "{}",
  47. fluent_i18n::t!("doctor-header", {
  48. "name" => dn,
  49. "engine" => de,
  50. "backend" => db,
  51. })
  52. );
  53. let (cluster, _nccl) = resolve_cluster(config, &recipe, cluster_name, &HashSet::new())?;
  54. // Check each node
  55. let mut all_results: Vec<DoctorResult> = Vec::new();
  56. let mut handles = Vec::new();
  57. for node in &cluster.nodes {
  58. let config = config.clone();
  59. let node = node.clone();
  60. let engine = recipe.engine.clone();
  61. let backend = recipe
  62. .distributed_backend
  63. .map_or("none".to_string(), |b| b.to_string());
  64. let image = recipe.docker_image.clone();
  65. handles.push(crate::locale::spawn_with_locale(async move {
  66. check_node(&config, &node, &engine, &backend, &image).await
  67. }));
  68. }
  69. let results = futures::future::join_all(handles).await;
  70. for r in results.into_iter().flatten() {
  71. all_results.push(r);
  72. }
  73. // Print results
  74. let mut has_failures = false;
  75. for result in &all_results {
  76. println!(
  77. "{}",
  78. fluent_i18n::t!("doctor-node-section", { "name" => &result.node_name })
  79. );
  80. for check in &result.checks {
  81. println!(" {} {} {}", check.status, check.name, check.detail);
  82. if check.status == DoctorStatus::Fail {
  83. has_failures = true;
  84. }
  85. }
  86. }
  87. if has_failures {
  88. if repair {
  89. println!("{}", fluent_i18n::t!("doctor-repairing"));
  90. let repaired = run_cluster_repair(config, &cluster, &recipe, dry_run).await?;
  91. println!(
  92. "{}",
  93. fluent_i18n::t!("doctor-repaired", { "tag" => &repaired })
  94. );
  95. } else {
  96. println!("{}", fluent_i18n::t!("doctor-checks-failed"));
  97. std::process::exit(1);
  98. }
  99. } else {
  100. println!("{}", fluent_i18n::t!("doctor-all-passed"));
  101. }
  102. Ok(())
  103. }
  104. /// Check a single node for required software.
  105. async fn check_node(
  106. config: &Config,
  107. node: &Node,
  108. engine: &str,
  109. backend: &str,
  110. image: &str,
  111. ) -> DoctorResult {
  112. let mut checks = Vec::new();
  113. checks.push(node_check(config, node, "Docker", "docker --version").await);
  114. checks.push(node_check(config, node, "NVIDIA GPU", "nvidia-smi").await);
  115. checks.push(
  116. node_check(
  117. config,
  118. node,
  119. "NCCL libs",
  120. "ldconfig -p | grep -q nccl && echo OK",
  121. )
  122. .await,
  123. );
  124. checks
  125. .push(container_check(config, node, image, "Engine", format!("{} version", engine)).await);
  126. if backend == "ray" {
  127. checks.push(container_check(config, node, image, "Ray", "ray --version".to_string()).await);
  128. }
  129. checks.push(container_entrypoint_check(config, node, image).await);
  130. DoctorResult {
  131. node_name: node.name.clone(),
  132. checks,
  133. }
  134. }
  135. /// Run a command on the node via SSH and return a check result.
  136. async fn node_check(config: &Config, node: &Node, name: &str, command: &str) -> DoctorCheck {
  137. match run_ssh(config, node, command.to_string(), false).await {
  138. Ok(output) if !output.trim().is_empty() => DoctorCheck {
  139. name: name.to_string(),
  140. status: DoctorStatus::Ok,
  141. detail: format!(
  142. "OK: {}",
  143. output.trim().lines().next().unwrap_or(output.trim())
  144. ),
  145. },
  146. Ok(_) => DoctorCheck {
  147. name: name.to_string(),
  148. status: DoctorStatus::Fail,
  149. detail: "not found".to_string(),
  150. },
  151. Err(e) => DoctorCheck {
  152. name: name.to_string(),
  153. status: DoctorStatus::Fail,
  154. detail: format!("error: {}", e),
  155. },
  156. }
  157. }
  158. /// Run a command inside a container on the node and return a check result.
  159. async fn container_check(
  160. config: &Config,
  161. node: &Node,
  162. image: &str,
  163. name: &str,
  164. command: String,
  165. ) -> DoctorCheck {
  166. let docker_cmd = format!("docker run --rm {} {} 2>&1 | head -1", image, command);
  167. match run_ssh(config, node, docker_cmd, false).await {
  168. Ok(output) if !output.contains("not found") && !output.contains("No such file") => {
  169. DoctorCheck {
  170. name: name.to_string(),
  171. status: DoctorStatus::Ok,
  172. detail: format!("OK: {}", output.trim()),
  173. }
  174. }
  175. Ok(output) => DoctorCheck {
  176. name: name.to_string(),
  177. status: DoctorStatus::Fail,
  178. detail: format!("not found: {}", output.trim()),
  179. },
  180. Err(e) => DoctorCheck {
  181. name: name.to_string(),
  182. status: DoctorStatus::Fail,
  183. detail: format!("error: {}", e),
  184. },
  185. }
  186. }
  187. /// Check the container entrypoint.
  188. async fn container_entrypoint_check(config: &Config, node: &Node, image: &str) -> DoctorCheck {
  189. let docker_cmd = format!(
  190. "docker inspect --format '{{{{json .Config.Entrypoint}}}}' {} 2>/dev/null || echo 'null'",
  191. image
  192. );
  193. match run_ssh(config, node, docker_cmd, false).await {
  194. Ok(output) => {
  195. let entrypoint = output.trim();
  196. if entrypoint == "null" || entrypoint == "[]" {
  197. DoctorCheck {
  198. name: "Entrypoint".to_string(),
  199. status: DoctorStatus::Ok,
  200. detail: "default (shell) entrypoint".to_string(),
  201. }
  202. } else {
  203. DoctorCheck {
  204. name: "Entrypoint".to_string(),
  205. status: DoctorStatus::Warn,
  206. detail: format!("custom: {}", entrypoint),
  207. }
  208. }
  209. }
  210. Err(e) => DoctorCheck {
  211. name: "Entrypoint".to_string(),
  212. status: DoctorStatus::Fail,
  213. detail: format!("error: {}", e),
  214. },
  215. }
  216. }
  217. /// Generate Dockerfile and build repaired image on head node.
  218. async fn run_cluster_repair(
  219. config: &Config,
  220. cluster: &Cluster,
  221. recipe: &Recipe,
  222. dry_run: bool,
  223. ) -> Result<String> {
  224. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  225. let repaired_tag = format!("{}.repaired", recipe.docker_image);
  226. let dockerfile = generate_repair_dockerfile(&repaired_tag, recipe)
  227. .context("Failed to generate repair Dockerfile")?;
  228. // The Dockerfile is sent to the node via a quoted heredoc below; no local
  229. // file write is needed (an earlier version wrote `recipes/temp/…` and
  230. // removed it — pointless, and it hardcoded "temp" instead of the recipe).
  231. let create_dockerfile = format!(
  232. "mkdir -p /tmp/sm121_docker && cat > /tmp/sm121_docker/Dockerfile << 'DOCKERFILE_END'\n{}DOCKERFILE_END",
  233. dockerfile
  234. );
  235. if !dry_run {
  236. run_ssh(config, head_node, create_dockerfile, false).await?;
  237. let build_cmd = format!("cd /tmp/sm121_docker && docker build -t {} .", repaired_tag);
  238. let output = run_ssh_with_retry(config, head_node, build_cmd, false, 1)
  239. .await
  240. .context("Failed to build repaired image")?;
  241. println!("Build output: {}", output.lines().last().unwrap_or(&output));
  242. } else {
  243. let dtag = repaired_tag.clone();
  244. let dnode = head_node.name.clone();
  245. println!(
  246. "{}",
  247. fluent_i18n::t!("doctor-dry-build", {
  248. "tag" => dtag,
  249. "node" => dnode,
  250. })
  251. );
  252. }
  253. Ok(repaired_tag)
  254. }
  255. /// Validate docker image name to prevent Dockerfile injection.
  256. /// Returns true if the image name is safe to use in a FROM statement.
  257. fn is_valid_docker_image(image: &str) -> bool {
  258. // Valid docker image pattern: [registry/]namespace/repo[:tag|@digest]
  259. // Allow alphanumeric, dots, dashes, underscores, slashes, colons, and @
  260. // No newlines, no shell metacharacters
  261. image
  262. .chars()
  263. .all(|c| c.is_alphanumeric() || ".-_/:@".contains(c))
  264. && !image.contains('\\')
  265. && !image.contains('\n')
  266. && !image.contains('\r')
  267. }
  268. /// Generate a Dockerfile to repair a container.
  269. /// Returns an error if `base_image` contains unsafe characters that
  270. /// could lead to Dockerfile injection.
  271. fn generate_repair_dockerfile(base_image: &str, recipe: &Recipe) -> Result<String> {
  272. if !is_valid_docker_image(base_image) {
  273. anyhow::bail!("Invalid docker image name: {}", base_image);
  274. }
  275. let mut lines = vec![format!("FROM {}", base_image)];
  276. if recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
  277. lines.push("RUN pip install ray[default] || pip install ray".to_string());
  278. }
  279. let engine_install = match recipe.engine.as_str() {
  280. "vllm" => "pip install vllm",
  281. "sglang" => "pip install sglang",
  282. _ => "",
  283. };
  284. if !engine_install.is_empty() {
  285. lines.push(format!("RUN {} || true", engine_install));
  286. }
  287. lines.push("ENTRYPOINT [\"/bin/bash\", \"-c\"]".to_string());
  288. Ok(lines.join("\n"))
  289. }
  290. /// Check if a repaired image exists on the head node.
  291. pub async fn try_repaired_image(
  292. config: &Config,
  293. cluster: &Cluster,
  294. recipe: &Recipe,
  295. ) -> Result<String> {
  296. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  297. let repaired_tag = format!("{}.repaired", recipe.docker_image);
  298. let check_cmd = format!("docker images -q {} | head -1", repaired_tag);
  299. match run_ssh(config, head_node, check_cmd, false).await {
  300. Ok(output) if !output.trim().is_empty() => {
  301. info!(
  302. "{}",
  303. fluent_i18n::t!("doctor-found-repaired", { "tag" => &repaired_tag })
  304. );
  305. Ok(repaired_tag)
  306. }
  307. _ => Ok(recipe.docker_image.clone()),
  308. }
  309. }
  310. // ====================== Tests ======================
  311. #[cfg(test)]
  312. mod tests {
  313. use super::*;
  314. use crate::pre_serve::VenvSetup;
  315. use std::collections::HashMap;
  316. fn test_recipe() -> Recipe {
  317. Recipe {
  318. build_profile: None,
  319. parent_model: None,
  320. archived: false,
  321. engine: "vllm".to_string(),
  322. name: "Test Model".to_string(),
  323. docker_image: "test/image:latest".to_string(),
  324. model: "test/model".to_string(),
  325. port: 8000,
  326. required_nodes: Some(2),
  327. pipeline_parallel_size: Some(2),
  328. tensor_parallel_size: 1,
  329. args: crate::pre_serve::RecipeArgs::List(vec![
  330. "--max-model-len".to_string(),
  331. "4096".to_string(),
  332. ]),
  333. env: HashMap::new(),
  334. cluster: None,
  335. entrypoint: vec![],
  336. distributed_backend: Some(crate::config::DistributedBackend::Mp),
  337. before_serve: vec![],
  338. after_stop: vec![],
  339. solo: None,
  340. deploy: crate::config::DeployTarget::Docker,
  341. venv: None,
  342. venv_setup: VenvSetup::default(),
  343. venv_setup_script: None,
  344. mods: Default::default(),
  345. sync_image: true,
  346. docker_image_dir: None,
  347. shm_size: String::new(),
  348. extra_mounts: vec![],
  349. build: Default::default(),
  350. composed_model: None,
  351. }
  352. }
  353. #[test]
  354. fn test_generate_repair_dockerfile_vllm() {
  355. let recipe = test_recipe();
  356. let dockerfile = generate_repair_dockerfile("base/image:tag", &recipe).unwrap();
  357. assert!(dockerfile.contains("FROM base/image:tag"));
  358. assert!(dockerfile.contains("pip install vllm"));
  359. assert!(!dockerfile.contains("ray"));
  360. assert!(dockerfile.contains("ENTRYPOINT"));
  361. }
  362. #[test]
  363. fn test_generate_repair_dockerfile_ray() {
  364. let mut recipe = test_recipe();
  365. recipe.distributed_backend = Some(crate::config::DistributedBackend::Ray);
  366. let dockerfile = generate_repair_dockerfile("base/image:tag", &recipe).unwrap();
  367. assert!(dockerfile.contains("FROM base/image:tag"));
  368. assert!(dockerfile.contains("pip install vllm"));
  369. assert!(dockerfile.contains("pip install ray"));
  370. }
  371. #[test]
  372. fn test_generate_repair_dockerfile_sglang() {
  373. let mut recipe = test_recipe();
  374. recipe.engine = "sglang".to_string();
  375. let dockerfile = generate_repair_dockerfile("base/image:tag", &recipe).unwrap();
  376. assert!(dockerfile.contains("pip install sglang"));
  377. assert!(!dockerfile.contains("vllm"));
  378. }
  379. }