exec.rs 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. use anyhow::{Context, Result, anyhow};
  2. use tracing::{info, warn};
  3. use crate::config::{Cluster, Config, Recipe, select_cluster};
  4. /// Resolve the target cluster for `cargo run exec` from `--cluster` or
  5. /// `--recipe` (exactly one of which must be given).
  6. pub async fn resolve_target(
  7. config: &Config,
  8. cluster_name: Option<&str>,
  9. recipe_name: Option<&str>,
  10. ) -> Result<(Cluster, Option<Recipe>)> {
  11. match (cluster_name, recipe_name) {
  12. (Some(_), Some(_)) => {
  13. anyhow::bail!("Specify either --cluster or --recipe, not both")
  14. }
  15. (None, None) => {
  16. anyhow::bail!("Must specify either --cluster or --recipe")
  17. }
  18. (Some(name), None) => {
  19. let cluster = crate::config::select_cluster_by_name(config, name)?;
  20. Ok((cluster, None))
  21. }
  22. (None, Some(rname)) => {
  23. let recipe = crate::config::load_recipe(rname).await?;
  24. let cname = recipe.cluster.as_deref().unwrap_or("auto");
  25. let (cluster, _) = select_cluster(config, cname)
  26. .with_context(|| format!("resolving cluster for recipe '{}'", rname))?;
  27. Ok((cluster, Some(recipe)))
  28. }
  29. }
  30. }
  31. /// Run `command` on every node of `cluster` (or a single node if `node`
  32. /// is given) and stream the output. Used by the `cargo run exec`
  33. /// subcommand to support mass-execution flows like `sudo shutdown now`.
  34. pub async fn run(
  35. config: &Config,
  36. cluster: &Cluster,
  37. node_name: Option<&str>,
  38. command: &str,
  39. dry_run: bool,
  40. ) -> Result<()> {
  41. let targets: Vec<_> = match node_name {
  42. Some(name) => cluster
  43. .nodes
  44. .iter()
  45. .filter(|n| n.name == name)
  46. .cloned()
  47. .collect(),
  48. None => cluster.nodes.clone(),
  49. };
  50. if targets.is_empty() {
  51. if let Some(n) = node_name {
  52. anyhow::bail!("Node '{}' not found in cluster '{}'", n, cluster.name);
  53. }
  54. anyhow::bail!("Cluster '{}' has no nodes", cluster.name);
  55. }
  56. info!("⚡ exec: {} ({} node(s))", command, targets.len());
  57. let mut handles = Vec::with_capacity(targets.len());
  58. for node in targets {
  59. let config = config.clone();
  60. let command = command.to_string();
  61. let dry = dry_run;
  62. handles.push(tokio::spawn(async move {
  63. let name = node.name.clone();
  64. let result = crate::ssh::run_ssh(&config, &node, command.clone(), dry).await;
  65. (name, command, result)
  66. }));
  67. }
  68. let mut any_failed = false;
  69. for h in handles {
  70. let (name, command, result) = h.await?;
  71. match result {
  72. Ok(output) => {
  73. if !output.trim().is_empty() {
  74. println!("\n--- {} ---", name);
  75. print!("{}", output);
  76. if !output.ends_with('\n') {
  77. println!();
  78. }
  79. }
  80. }
  81. Err(e) => {
  82. any_failed = true;
  83. warn!("✗ {} :: {} :: {}", name, command, e);
  84. }
  85. }
  86. }
  87. if any_failed {
  88. warn!("exec completed with errors (some nodes reported failures)");
  89. }
  90. Ok(())
  91. }
  92. /// Resolve the command to execute for `cargo run exec`.
  93. ///
  94. /// - `--alias <name>` looks up `name` in `config.exec_aliases` and returns
  95. /// the configured shell command. Errors if not found.
  96. /// - Otherwise joins the trailing positional args into a single command
  97. /// line (must be non-empty).
  98. ///
  99. /// Exactly one of `alias_name` or `command_args` must be provided.
  100. pub fn resolve_command(
  101. config: &Config,
  102. alias_name: Option<&str>,
  103. command_args: &[String],
  104. ) -> Result<String> {
  105. let has_alias = alias_name.is_some();
  106. let has_args = !command_args.is_empty();
  107. match (has_alias, has_args) {
  108. (true, false) => {
  109. let name = alias_name.expect("checked by has_alias");
  110. config.exec_aliases.get(name).cloned().ok_or_else(|| {
  111. let mut known: Vec<String> = config.exec_aliases.keys().cloned().collect();
  112. known.sort();
  113. if known.is_empty() {
  114. anyhow!("Unknown alias @{} (no aliases configured)", name)
  115. } else {
  116. anyhow!(
  117. "Unknown alias @{}; known aliases: {}",
  118. name,
  119. known.join(", ")
  120. )
  121. }
  122. })
  123. }
  124. (true, true) => {
  125. anyhow::bail!("Cannot use --alias together with a trailing command")
  126. }
  127. (false, false) => {
  128. anyhow::bail!("Must specify either --alias <name> or a command after `--`")
  129. }
  130. (false, true) => Ok(command_args.join(" ")),
  131. }
  132. }
  133. #[cfg(test)]
  134. mod tests {
  135. use super::*;
  136. use std::collections::HashMap;
  137. fn test_config(aliases: &[(&str, &str)]) -> Config {
  138. let exec_aliases: HashMap<String, String> = aliases
  139. .iter()
  140. .map(|(k, v)| (k.to_string(), v.to_string()))
  141. .collect();
  142. Config {
  143. ssh_user: "u".to_string(),
  144. ssh_key_path: "/k".to_string(),
  145. clusters: vec![],
  146. nccl: HashMap::new(),
  147. hf_cache_dir: None,
  148. init_port: 25000,
  149. before_serve: HashMap::new(),
  150. exec_aliases,
  151. }
  152. }
  153. #[test]
  154. fn resolve_command_alias() {
  155. let cfg = test_config(&[("gpu-lock", "sudo nvidia-smi -lgc 300,2300")]);
  156. let cmd = resolve_command(&cfg, Some("gpu-lock"), &[]).unwrap();
  157. assert_eq!(cmd, "sudo nvidia-smi -lgc 300,2300");
  158. }
  159. #[test]
  160. fn resolve_command_unknown_alias_lists_known() {
  161. let cfg = test_config(&[("gpu-lock", "a"), ("shutdown", "b")]);
  162. let err = resolve_command(&cfg, Some("nope"), &[]).unwrap_err();
  163. let msg = err.to_string();
  164. assert!(msg.contains("Unknown alias @nope"), "got: {msg}");
  165. assert!(msg.contains("gpu-lock"), "got: {msg}");
  166. assert!(msg.contains("shutdown"), "got: {msg}");
  167. }
  168. #[test]
  169. fn resolve_command_unknown_alias_no_aliases() {
  170. let cfg = test_config(&[]);
  171. let err = resolve_command(&cfg, Some("anything"), &[]).unwrap_err();
  172. let msg = err.to_string();
  173. assert!(msg.contains("no aliases configured"), "got: {msg}");
  174. }
  175. #[test]
  176. fn resolve_command_no_alias_no_args_errors() {
  177. let cfg = test_config(&[]);
  178. let err = resolve_command(&cfg, None, &[]).unwrap_err();
  179. let msg = err.to_string();
  180. assert!(msg.contains("Must specify"), "got: {msg}");
  181. }
  182. #[test]
  183. fn resolve_command_alias_and_args_errors() {
  184. let cfg = test_config(&[("gpu", "a")]);
  185. let err = resolve_command(&cfg, Some("gpu"), &["ls".into()]).unwrap_err();
  186. let msg = err.to_string();
  187. assert!(msg.contains("Cannot use --alias together"), "got: {msg}");
  188. }
  189. #[test]
  190. fn resolve_command_literal_join() {
  191. let cfg = test_config(&[]);
  192. let cmd =
  193. resolve_command(&cfg, None, &["echo".into(), "hello".into(), "world".into()]).unwrap();
  194. assert_eq!(cmd, "echo hello world");
  195. }
  196. }