cli.rs 11 KB

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