cli.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  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 (following is the default).
  37. #[arg(long, action = clap::ArgAction::SetTrue)]
  38. no_follow: bool,
  39. /// Power on cluster if switch is off (requires HA config).
  40. #[arg(short = 'O', long = "on")]
  41. auto_power_on: bool,
  42. /// Sync Docker image across nodes before starting.
  43. #[arg(long, default_value_t = true)]
  44. sync_image: bool,
  45. /// Explicit source node for image sync (default: auto-detect).
  46. #[arg(long)]
  47. sync_image_source: Option<String>,
  48. /// Build image on this node if not found (requires docker-images/<recipe>/).
  49. #[arg(long)]
  50. build_node: Option<String>,
  51. /// Force rebuild even if checksum matches.
  52. #[arg(long)]
  53. force_build: bool,
  54. /// Run even if nodes are occupied (another container running).
  55. /// Skips the occupancy check during cluster selection — use to run
  56. /// a lightweight test recipe alongside a live serve, or to force
  57. /// onto a busy node. Dangerous for GPU recipes (OOM, port
  58. /// conflicts); the operator is responsible for coexistence.
  59. #[arg(long)]
  60. force: bool,
  61. /// Serve single-node on the first free node found (overrides recipe
  62. /// `solo`). Use `--no-solo` to force multi-node even if the recipe
  63. /// sets `solo = true`.
  64. #[arg(long)]
  65. solo: Option<bool>,
  66. /// Mirror the model into a per-node local folder and mount that
  67. /// instead of the shared HF cache (first serve copies the model from
  68. /// the shared cache; later serves load it locally). Without a value
  69. /// uses ~/.cache/hf-models; `--mirror <path>` uses that exact path.
  70. #[arg(long, num_args = 0..=1, default_missing_value = "")]
  71. mirror: Option<String>,
  72. /// Scale the recipe to a node count and let tensor-parallel track it
  73. /// (overrides `required_nodes` and `tensor_parallel_size`).
  74. /// `--fit=N` scales to N nodes; bare `--fit` uses the whole selected
  75. /// cluster. Incompatible with `--solo`. `pipeline_parallel_size` is
  76. /// left untouched.
  77. #[arg(long, num_args = 0..=1, default_missing_value = "")]
  78. fit: Option<String>,
  79. /// Clear the host-side `extra_mounts` folders (alias/host) on the
  80. /// launch nodes with `sudo rm -rf` before containers start — the
  81. /// containers run as root, so caches there are usually root-owned.
  82. /// Repo-based mounts (HF cache path refs) are never cleared. `-d`
  83. /// prints the exact command instead of running it.
  84. #[arg(long)]
  85. clear: bool,
  86. },
  87. /// Show container logs
  88. Logs {
  89. #[arg(long, required = true)]
  90. recipe: String,
  91. #[arg(long, action = clap::ArgAction::SetTrue)]
  92. follow: bool,
  93. #[arg(long, default_value = "auto")]
  94. cluster: String,
  95. /// Target a specific node within the cluster (default: head node if following, all nodes if not).
  96. #[arg(long)]
  97. node: Option<String>,
  98. },
  99. /// Stop a cluster
  100. Stop {
  101. #[arg(long, required = true)]
  102. recipe: String,
  103. /// Target specific nodes (repeatable, e.g. --node dgx1 --node dgx2).
  104. #[arg(long)]
  105. node: Vec<String>,
  106. #[arg(long, default_value = "auto")]
  107. cluster: String,
  108. /// Power off cluster after stopping containers (requires HA config).
  109. #[arg(long = "off")]
  110. power_off: bool,
  111. },
  112. /// Check cluster health
  113. Health {
  114. #[arg(
  115. long,
  116. help = "Cluster name (e.g. 4nodes-switch). If omitted — checks all."
  117. )]
  118. cluster: Option<String>,
  119. #[arg(long, help = "Show node host keys (for adding to config.toml)")]
  120. fingerprint: bool,
  121. /// Output format (cluster-health mode only; ignored with --fingerprint).
  122. #[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
  123. format: OutputFormat,
  124. },
  125. /// Check and repair a recipe (doctor mode)
  126. Doctor {
  127. #[arg(long, required = true, help = "Recipe name to check")]
  128. recipe: String,
  129. #[arg(
  130. long,
  131. default_value = "auto",
  132. help = "Cluster (if not auto — uses recipe.cluster)"
  133. )]
  134. cluster: String,
  135. #[arg(long, help = "Auto-fix issues (create Dockerfile + build image)")]
  136. repair: bool,
  137. },
  138. /// Publish a recipe (+ image overlay) as a standalone reproducible bundle
  139. Publish(PublishArgs),
  140. /// Browse and install recipes from the public registry
  141. #[command(subcommand)]
  142. Registry(RegistryCommands),
  143. /// Manage tmux sessions (Docker alternative)
  144. #[command(subcommand)]
  145. Tmux(TmuxCommands),
  146. /// Auto-discover host keys and write them into config.toml
  147. InitHostKeys {
  148. #[arg(long, help = "Cluster name (e.g. 4nodes-switch). If omitted — all.")]
  149. cluster: Option<String>,
  150. },
  151. /// Run an arbitrary shell command on a cluster or single node
  152. Exec {
  153. /// Cluster name (mutually exclusive with --recipe).
  154. #[arg(long, conflicts_with = "recipe")]
  155. cluster: Option<String>,
  156. /// Recipe name; resolves to the recipe's pinned cluster.
  157. #[arg(long, conflicts_with = "cluster")]
  158. recipe: Option<String>,
  159. /// Target a specific node within the cluster (default: all).
  160. #[arg(long)]
  161. node: Option<String>,
  162. /// Name of an alias from `[exec_aliases]` in config.toml.
  163. /// Mutually exclusive with a trailing command.
  164. #[arg(long)]
  165. alias: Option<String>,
  166. /// Command to run (everything after `--`).
  167. #[arg(trailing_var_arg = true)]
  168. command: Vec<String>,
  169. },
  170. /// Manage configuration (global config path, init)
  171. #[command(subcommand)]
  172. Config(ConfigCommands),
  173. /// Launch the web dashboard (axum + SSE) on a local port
  174. Web {
  175. #[arg(long, default_value_t = 3100)]
  176. port: u16,
  177. #[arg(long, default_value = "127.0.0.1")]
  178. bind: String,
  179. /// Bearer token for data endpoints (required when --bind is not loopback)
  180. #[arg(long)]
  181. token: Option<String>,
  182. /// Open the browser once the server is up
  183. #[arg(long)]
  184. open: bool,
  185. },
  186. /// Build Docker image for a recipe on a remote node
  187. Build {
  188. #[arg(long, required = true, help = "Recipe name")]
  189. recipe: String,
  190. #[arg(long, required = true, help = "Node to build on (e.g. dgx1.lan)")]
  191. build_node: String,
  192. #[arg(long, default_value = "auto", help = "Cluster name")]
  193. cluster: String,
  194. /// Force rebuild even if checksum matches.
  195. #[arg(long)]
  196. force_build: bool,
  197. },
  198. /// Power on/off nodes via Home Assistant (smart plugs / Wake-on-LAN).
  199. Power {
  200. #[command(subcommand)]
  201. action: PowerAction,
  202. },
  203. }
  204. #[derive(Subcommand)]
  205. pub enum PowerAction {
  206. /// Power off: SSH `sudo shutdown now` + wait + HA `turn_off`.
  207. Off {
  208. #[arg(long)]
  209. cluster: String,
  210. /// Target a specific node within the cluster (default: all).
  211. #[arg(long)]
  212. node: Option<String>,
  213. },
  214. /// Power on: HA `turn_on` only (no SSH; the smart plug wakes the machine).
  215. On {
  216. #[arg(long)]
  217. cluster: String,
  218. /// Target a specific node within the cluster (default: all).
  219. #[arg(long)]
  220. node: Option<String>,
  221. },
  222. /// Switch clusters: disable nodes from source, enable nodes in target.
  223. Switch {
  224. /// Current cluster (nodes + entities to shut down).
  225. #[arg(long)]
  226. from: String,
  227. /// Target cluster (nodes + entities to wake up).
  228. #[arg(long)]
  229. to: String,
  230. },
  231. }
  232. /// `sm121 config` subcommands — manage the global config file.
  233. #[derive(Subcommand)]
  234. pub enum ConfigCommands {
  235. /// Show the active config path and where the global config lives
  236. Path,
  237. /// Create the global config file (template) if it does not exist
  238. InitGlobal,
  239. }
  240. #[derive(Args)]
  241. pub struct PublishArgs {
  242. #[arg(long, required = true)]
  243. pub recipe: String,
  244. /// Cluster whose fabric env + node count fill the generated run script
  245. #[arg(long)]
  246. pub cluster: Option<String>,
  247. /// Output dir (default: ./publish/<recipe>)
  248. #[arg(long)]
  249. pub out: Option<std::path::PathBuf>,
  250. /// Also create a public GitHub gist (requires `gh` CLI, authenticated)
  251. #[arg(long)]
  252. pub gist: bool,
  253. /// Upload the bundle to a recipe registry (see `sm121 registry --help`)
  254. #[arg(long, value_name = "URL")]
  255. pub registry: Option<String>,
  256. /// Registry publish token (or $SM121RECIPES_TOKEN)
  257. #[arg(long)]
  258. pub token: Option<String>,
  259. }
  260. #[derive(Subcommand)]
  261. pub enum RegistryCommands {
  262. /// List recipes on the registry
  263. List {
  264. #[arg(long, value_name = "URL")]
  265. registry: Option<String>,
  266. },
  267. /// Print a registry recipe (recipe.toml)
  268. Show {
  269. name: String,
  270. #[arg(long, value_name = "URL")]
  271. registry: Option<String>,
  272. },
  273. /// Download a recipe into ./recipes/<name>/ (+ ./docker-images/<name>/ if it has an image)
  274. Install {
  275. name: String,
  276. #[arg(long, value_name = "URL")]
  277. registry: Option<String>,
  278. /// Overwrite an existing ./recipes/<name>/
  279. #[arg(long)]
  280. force: bool,
  281. },
  282. }
  283. #[derive(Subcommand)]
  284. pub enum TmuxCommands {
  285. /// Launch cluster via tmux
  286. Serve {
  287. #[arg(long, required = true)]
  288. recipe: String,
  289. #[arg(long, default_value = "auto")]
  290. cluster: String,
  291. /// Skip pre-serve hooks (cluster-level + recipe-level).
  292. #[arg(long)]
  293. skip_before_serve: bool,
  294. },
  295. /// Stop tmux sessions
  296. Stop {
  297. #[arg(long, required = true)]
  298. recipe: String,
  299. #[arg(long, default_value = "auto")]
  300. cluster: String,
  301. },
  302. /// Detailed tmux session status
  303. Status {
  304. #[arg(long, required = true)]
  305. recipe: String,
  306. #[arg(long, default_value = "auto")]
  307. cluster: String,
  308. },
  309. /// Show tmux session logs
  310. Logs {
  311. #[arg(long, required = true)]
  312. recipe: String,
  313. #[arg(long, action = clap::ArgAction::SetTrue)]
  314. follow: bool,
  315. #[arg(long, default_value = "auto")]
  316. cluster: String,
  317. },
  318. /// Attach to head node tmux session
  319. Attach {
  320. #[arg(long, required = true)]
  321. recipe: String,
  322. #[arg(long, default_value = "auto")]
  323. cluster: String,
  324. },
  325. }
  326. #[cfg(test)]
  327. mod tests {
  328. use super::*;
  329. /// Parse `sm121 health <args>` and return the parsed `--format`.
  330. fn health_format(args: &[&str]) -> Result<(OutputFormat, bool), clap::Error> {
  331. let cli = Cli::try_parse_from(
  332. std::iter::once("sm121")
  333. .chain(std::iter::once("health"))
  334. .chain(args.iter().copied()),
  335. )?;
  336. let Commands::Health {
  337. fingerprint,
  338. format,
  339. ..
  340. } = cli.command
  341. else {
  342. panic!("expected Health")
  343. };
  344. Ok((format, fingerprint))
  345. }
  346. #[test]
  347. fn health_format_defaults_to_plain() {
  348. assert_eq!(health_format(&[]).unwrap(), (OutputFormat::Plain, false));
  349. }
  350. #[test]
  351. fn health_format_parses_each_variant() {
  352. assert_eq!(
  353. health_format(&["--format", "plain"]).unwrap().0,
  354. OutputFormat::Plain
  355. );
  356. assert_eq!(
  357. health_format(&["--format", "md"]).unwrap().0,
  358. OutputFormat::Md
  359. );
  360. assert_eq!(
  361. health_format(&["--format", "html"]).unwrap().0,
  362. OutputFormat::Html
  363. );
  364. }
  365. #[test]
  366. fn health_format_combines_with_cluster_and_fingerprint() {
  367. let (fmt, fp) = health_format(&["--cluster", "c1", "--format", "md"]).unwrap();
  368. assert_eq!((fmt, fp), (OutputFormat::Md, false));
  369. let (fmt, fp) = health_format(&["--fingerprint", "--format", "html"]).unwrap();
  370. assert_eq!((fmt, fp), (OutputFormat::Html, true));
  371. }
  372. #[test]
  373. fn health_format_rejects_invalid_value() {
  374. assert!(health_format(&["--format", "bogus"]).is_err());
  375. }
  376. }