cli.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. use clap::{Args, Parser, Subcommand, ValueEnum};
  2. /// Output format for human-readable reports (`--format`).
  3. #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
  4. pub enum OutputFormat {
  5. Plain,
  6. Md,
  7. Html,
  8. }
  9. #[derive(Parser)]
  10. #[command(author, version, about = "DGX Spark Launcher for vLLM and SGLang")]
  11. pub struct Cli {
  12. #[arg(long, short, default_value = "config.toml")]
  13. pub config: String,
  14. #[arg(long, short, help = "Show commands without executing")]
  15. pub dry_run: bool,
  16. #[command(subcommand)]
  17. pub command: Commands,
  18. }
  19. #[derive(Subcommand)]
  20. pub enum Commands {
  21. /// Launch a cluster
  22. Serve {
  23. #[arg(long, required = true)]
  24. recipe: String,
  25. #[arg(long, default_value = "auto")]
  26. cluster: String,
  27. /// Target a specific node within the cluster (default: all).
  28. #[arg(long)]
  29. node: Option<String>,
  30. /// Keep containers after stop (default: auto-remove on stop)
  31. #[arg(long)]
  32. keep: bool,
  33. /// Skip pre-serve hooks (cluster-level + recipe-level).
  34. #[arg(long)]
  35. skip_before_serve: bool,
  36. /// Don't follow logs after serving (default: follow).
  37. #[arg(long, action = clap::ArgAction::SetTrue)]
  38. no_follow: bool,
  39. /// Skip the engine-readiness wait after launch (no /v1/models poll).
  40. #[arg(long)]
  41. no_wait: bool,
  42. /// Power on cluster if switch is off (requires HA config).
  43. #[arg(short = 'O', long = "on")]
  44. auto_power_on: bool,
  45. /// Sync Docker image across nodes before starting.
  46. #[arg(long, default_value_t = true)]
  47. sync_image: bool,
  48. /// Explicit source node for image sync (default: auto-detect).
  49. #[arg(long)]
  50. sync_image_source: Option<String>,
  51. /// Build image on this node if not found (requires docker-images/<recipe>/).
  52. #[arg(long)]
  53. build_node: Option<String>,
  54. /// Force rebuild even if checksum matches.
  55. #[arg(long)]
  56. force_build: bool,
  57. /// Run even on occupied nodes (dangerous for GPU recipes: OOM,
  58. /// port conflicts). Skips the occupancy check in cluster selection.
  59. #[arg(long)]
  60. force: bool,
  61. /// Serve single-node on the first free node (overrides recipe `solo`);
  62. /// `--no-solo` forces multi-node.
  63. #[arg(long)]
  64. solo: Option<bool>,
  65. /// Mount a per-node local HF mirror (bare = ~/.cache/hf-models;
  66. /// `--mirror <path>` = that path; first serve copies per node).
  67. #[arg(long, num_args = 0..=1, default_missing_value = "")]
  68. mirror: Option<String>,
  69. /// Scale the recipe to N nodes (`--fit=N`; bare = whole cluster, TP
  70. /// tracks it; incompatible with --solo).
  71. #[arg(long, num_args = 0..=1, default_missing_value = "")]
  72. fit: Option<String>,
  73. /// `sudo rm -rf` the host-side extra_mounts folders on the launch
  74. /// nodes before containers start (repo-based mounts untouched).
  75. #[arg(long)]
  76. clear: bool,
  77. },
  78. /// Show container logs
  79. Logs {
  80. #[arg(long, required = true)]
  81. recipe: String,
  82. #[arg(long, action = clap::ArgAction::SetTrue)]
  83. follow: bool,
  84. #[arg(long, default_value = "auto")]
  85. cluster: String,
  86. /// Target a specific node within the cluster (default: head node if following, all nodes if not).
  87. #[arg(long)]
  88. node: Option<String>,
  89. },
  90. /// Stop a cluster
  91. Stop {
  92. #[arg(long, required = true)]
  93. recipe: String,
  94. /// Target specific nodes (repeatable, e.g. --node dgx1 --node dgx2).
  95. #[arg(long)]
  96. node: Vec<String>,
  97. #[arg(long, default_value = "auto")]
  98. cluster: String,
  99. /// Power off cluster after stopping containers (requires HA config).
  100. #[arg(long = "off")]
  101. power_off: bool,
  102. /// Don't wait until the containers are actually stopped and removed
  103. /// (default: wait and verify, up to 60 s per node, so an immediate
  104. /// re-serve on the same nodes doesn't race the dying containers).
  105. #[arg(long)]
  106. no_wait: bool,
  107. },
  108. /// Bench a live serve with tool-eval-bench: reports auto-land in
  109. /// recipes/<lane>/reports/<UTC-date>_<label>.{md,json,log} (seed 42, hardmode, perf).
  110. Bench {
  111. #[arg(long, required = true)]
  112. recipe: String,
  113. #[arg(long, required = true)]
  114. cluster: String,
  115. #[arg(long, required = true)]
  116. label: String,
  117. #[arg(long, default_value = "1,2")]
  118. concurrency: String,
  119. #[arg(long, default_value_t = 42)]
  120. seed: u64,
  121. },
  122. /// Check cluster health
  123. Health {
  124. #[arg(
  125. long,
  126. help = "Cluster name (e.g. 4nodes-switch). If omitted — checks all."
  127. )]
  128. cluster: Option<String>,
  129. #[arg(long, help = "Show node host keys (for adding to config.toml)")]
  130. fingerprint: bool,
  131. /// Output format (cluster-health mode only; ignored with --fingerprint).
  132. #[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
  133. format: OutputFormat,
  134. },
  135. /// Check and repair a recipe (doctor mode)
  136. Doctor {
  137. #[arg(long, required = true, help = "Recipe name to check")]
  138. recipe: String,
  139. #[arg(
  140. long,
  141. default_value = "auto",
  142. help = "Cluster (if not auto — uses recipe.cluster)"
  143. )]
  144. cluster: String,
  145. #[arg(long, help = "Auto-fix issues (create Dockerfile + build image)")]
  146. repair: bool,
  147. },
  148. /// Publish a recipe (+ image overlay) as a standalone reproducible bundle
  149. Publish(PublishArgs),
  150. /// Browse and install recipes from the public registry
  151. #[command(subcommand)]
  152. Registry(RegistryCommands),
  153. /// Manage tmux sessions (Docker alternative)
  154. #[command(subcommand)]
  155. Tmux(TmuxCommands),
  156. /// Auto-discover host keys and write them into config.toml
  157. InitHostKeys {
  158. #[arg(long, help = "Cluster name (e.g. 4nodes-switch). If omitted — all.")]
  159. cluster: Option<String>,
  160. },
  161. /// Run an arbitrary shell command on a cluster or single node
  162. Exec {
  163. /// Cluster name (mutually exclusive with --recipe).
  164. #[arg(long, conflicts_with = "recipe")]
  165. cluster: Option<String>,
  166. /// Recipe name; resolves to the recipe's pinned cluster.
  167. #[arg(long, conflicts_with = "cluster")]
  168. recipe: Option<String>,
  169. /// Target a specific node within the cluster (default: all).
  170. #[arg(long)]
  171. node: Option<String>,
  172. /// Name of an alias from `[exec_aliases]` in config.toml.
  173. /// Mutually exclusive with a trailing command.
  174. #[arg(long)]
  175. alias: Option<String>,
  176. /// Command to run (everything after `--`).
  177. #[arg(trailing_var_arg = true)]
  178. command: Vec<String>,
  179. },
  180. /// Manage configuration (global config path, init)
  181. #[command(subcommand)]
  182. Config(ConfigCommands),
  183. /// Launch the web dashboard (axum + SSE) on a local port
  184. Web {
  185. #[arg(long, default_value_t = 3100)]
  186. port: u16,
  187. #[arg(long, default_value = "127.0.0.1")]
  188. bind: String,
  189. /// Bearer token for data endpoints (required when --bind is not loopback)
  190. #[arg(long)]
  191. token: Option<String>,
  192. /// Open the browser once the server is up
  193. #[arg(long)]
  194. open: bool,
  195. },
  196. /// Table of all tested recipes and their benchmark history
  197. Report {
  198. /// Interactive HTML table via --serve (default: print Markdown)
  199. #[arg(long)]
  200. serve: bool,
  201. /// Port for --serve
  202. #[arg(long, default_value_t = 3101)]
  203. port: u16,
  204. /// Open the browser (implies --serve)
  205. #[arg(long)]
  206. open: bool,
  207. #[arg(long, help = "Archive a recipe (hidden from report output)")]
  208. archive: Option<String>,
  209. #[arg(long, help = "Un-archive a recipe")]
  210. unarchive: Option<String>,
  211. #[arg(long, help = "Show the archived recipes instead of the active ones")]
  212. archived: bool,
  213. },
  214. /// Build Docker image for a recipe on a remote node
  215. Build {
  216. #[arg(long, required = true, help = "Recipe name")]
  217. recipe: String,
  218. #[arg(long, required = true, help = "Node to build on (e.g. dgx1.lan)")]
  219. build_node: String,
  220. #[arg(long, default_value = "auto", help = "Cluster name")]
  221. cluster: String,
  222. /// Force rebuild even if checksum matches.
  223. #[arg(long)]
  224. force_build: bool,
  225. },
  226. /// Power on/off nodes via Home Assistant (smart plugs / Wake-on-LAN).
  227. Power {
  228. #[command(subcommand)]
  229. action: PowerAction,
  230. },
  231. }
  232. #[derive(Subcommand)]
  233. pub enum PowerAction {
  234. /// Power off: SSH `sudo shutdown now` + wait + HA `turn_off`.
  235. Off {
  236. #[arg(long)]
  237. cluster: String,
  238. /// Target a specific node within the cluster (default: all).
  239. #[arg(long)]
  240. node: Option<String>,
  241. },
  242. /// Power on: HA `turn_on` only (no SSH; the smart plug wakes the machine).
  243. On {
  244. #[arg(long)]
  245. cluster: String,
  246. /// Target a specific node within the cluster (default: all).
  247. #[arg(long)]
  248. node: Option<String>,
  249. },
  250. /// Switch clusters: disable nodes from source, enable nodes in target.
  251. Switch {
  252. /// Current cluster (nodes + entities to shut down).
  253. #[arg(long)]
  254. from: String,
  255. /// Target cluster (nodes + entities to wake up).
  256. #[arg(long)]
  257. to: String,
  258. },
  259. }
  260. /// `sm121 config` subcommands — manage the global config file.
  261. #[derive(Subcommand)]
  262. pub enum ConfigCommands {
  263. /// Show the active config path and where the global config lives
  264. Path,
  265. /// Create the global config file (template) if it does not exist
  266. InitGlobal,
  267. }
  268. #[derive(Args)]
  269. pub struct PublishArgs {
  270. #[arg(long, required = true)]
  271. pub recipe: String,
  272. /// Cluster whose fabric env + node count fill the generated run script
  273. #[arg(long)]
  274. pub cluster: Option<String>,
  275. /// Output dir (default: ./publish/<recipe>)
  276. #[arg(long)]
  277. pub out: Option<std::path::PathBuf>,
  278. /// Also create a public GitHub gist (requires `gh` CLI, authenticated)
  279. #[arg(long)]
  280. pub gist: bool,
  281. /// Upload the bundle to a recipe registry (see `sm121 registry --help`)
  282. #[arg(long, value_name = "URL")]
  283. pub registry: Option<String>,
  284. /// Registry publish token (or $SM121RECIPES_TOKEN)
  285. #[arg(long)]
  286. pub token: Option<String>,
  287. }
  288. #[derive(Subcommand)]
  289. pub enum RegistryCommands {
  290. /// List recipes on the registry
  291. List {
  292. #[arg(long, value_name = "URL")]
  293. registry: Option<String>,
  294. },
  295. /// Print a registry recipe (recipe.toml)
  296. Show {
  297. name: String,
  298. #[arg(long, value_name = "URL")]
  299. registry: Option<String>,
  300. },
  301. /// Download a recipe into ./recipes/<name>/ (+ ./docker-images/<name>/ if it has an image)
  302. Install {
  303. name: String,
  304. #[arg(long, value_name = "URL")]
  305. registry: Option<String>,
  306. /// Overwrite an existing ./recipes/<name>/
  307. #[arg(long)]
  308. force: bool,
  309. },
  310. }
  311. #[derive(Subcommand)]
  312. pub enum TmuxCommands {
  313. /// Launch cluster via tmux
  314. Serve {
  315. #[arg(long, required = true)]
  316. recipe: String,
  317. #[arg(long, default_value = "auto")]
  318. cluster: String,
  319. /// Skip pre-serve hooks (cluster-level + recipe-level).
  320. #[arg(long)]
  321. skip_before_serve: bool,
  322. },
  323. /// Stop tmux sessions
  324. Stop {
  325. #[arg(long, required = true)]
  326. recipe: String,
  327. #[arg(long, default_value = "auto")]
  328. cluster: String,
  329. },
  330. /// Detailed tmux session status
  331. Status {
  332. #[arg(long, required = true)]
  333. recipe: String,
  334. #[arg(long, default_value = "auto")]
  335. cluster: String,
  336. },
  337. /// Show tmux session logs
  338. Logs {
  339. #[arg(long, required = true)]
  340. recipe: String,
  341. #[arg(long, action = clap::ArgAction::SetTrue)]
  342. follow: bool,
  343. #[arg(long, default_value = "auto")]
  344. cluster: String,
  345. },
  346. /// Attach to head node tmux session
  347. Attach {
  348. #[arg(long, required = true)]
  349. recipe: String,
  350. #[arg(long, default_value = "auto")]
  351. cluster: String,
  352. },
  353. }
  354. #[cfg(test)]
  355. mod tests {
  356. use super::*;
  357. /// Parse `sm121 health <args>` and return the parsed `--format`.
  358. fn health_format(args: &[&str]) -> Result<(OutputFormat, bool), clap::Error> {
  359. let cli = Cli::try_parse_from(
  360. std::iter::once("sm121")
  361. .chain(std::iter::once("health"))
  362. .chain(args.iter().copied()),
  363. )?;
  364. let Commands::Health {
  365. fingerprint,
  366. format,
  367. ..
  368. } = cli.command
  369. else {
  370. panic!("expected Health")
  371. };
  372. Ok((format, fingerprint))
  373. }
  374. #[test]
  375. fn health_format_defaults_to_plain() {
  376. assert_eq!(health_format(&[]).unwrap(), (OutputFormat::Plain, false));
  377. }
  378. #[test]
  379. fn health_format_parses_each_variant() {
  380. assert_eq!(
  381. health_format(&["--format", "plain"]).unwrap().0,
  382. OutputFormat::Plain
  383. );
  384. assert_eq!(
  385. health_format(&["--format", "md"]).unwrap().0,
  386. OutputFormat::Md
  387. );
  388. assert_eq!(
  389. health_format(&["--format", "html"]).unwrap().0,
  390. OutputFormat::Html
  391. );
  392. }
  393. #[test]
  394. fn health_format_combines_with_cluster_and_fingerprint() {
  395. let (fmt, fp) = health_format(&["--cluster", "c1", "--format", "md"]).unwrap();
  396. assert_eq!((fmt, fp), (OutputFormat::Md, false));
  397. let (fmt, fp) = health_format(&["--fingerprint", "--format", "html"]).unwrap();
  398. assert_eq!((fmt, fp), (OutputFormat::Html, true));
  399. }
  400. #[test]
  401. fn health_format_rejects_invalid_value() {
  402. assert!(health_format(&["--format", "bogus"]).is_err());
  403. }
  404. #[test]
  405. fn report_flag_defaults_and_overrides() {
  406. let cli = Cli::try_parse_from(["sm121", "report"]).unwrap();
  407. let Commands::Report {
  408. serve,
  409. port,
  410. open,
  411. archive: _,
  412. unarchive: _,
  413. archived: _,
  414. } = cli.command
  415. else {
  416. unreachable!()
  417. };
  418. assert!(!serve && !open);
  419. assert_eq!(port, 3101);
  420. let cli = Cli::try_parse_from(["sm121", "report", "--open", "--port", "4000"]).unwrap();
  421. let Commands::Report { open, port, .. } = cli.command else {
  422. unreachable!()
  423. };
  424. assert!(open);
  425. assert_eq!(port, 4000);
  426. }
  427. }