cli.rs 15 KB

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