cli.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. use crate::runlock::RunlockCommands;
  2. use clap::{Parser, Subcommand, ValueEnum};
  3. pub use crate::cli_sub::{
  4. ConfigCommands, PowerAction, PublishArgs, RegistryCommands, ReposArgs, ReposCommands,
  5. SyncImageArgs, TmuxCommands,
  6. };
  7. /// Output format for human-readable reports (`--format`).
  8. #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
  9. pub enum OutputFormat {
  10. Plain,
  11. Md,
  12. Html,
  13. }
  14. #[derive(Parser)]
  15. #[command(author, version, about = "DGX Spark Launcher for vLLM and SGLang")]
  16. pub struct Cli {
  17. #[arg(long, short, default_value = "config.toml")]
  18. pub config: String,
  19. #[arg(long, short, help = "Show commands without executing")]
  20. pub dry_run: bool,
  21. #[command(subcommand)]
  22. pub command: Commands,
  23. }
  24. #[derive(Subcommand)]
  25. pub enum Commands {
  26. /// Distribute a recipe's image across the cluster nodes (node-to-node,
  27. /// one save + parallel fan-out; no serve started).
  28. SyncImage(SyncImageArgs),
  29. /// Launch a cluster
  30. Serve {
  31. #[arg(long, required = true)]
  32. recipe: String,
  33. #[arg(long, default_value = "auto")]
  34. cluster: String,
  35. /// Target a specific node within the cluster (default: all).
  36. #[arg(long)]
  37. node: Option<String>,
  38. /// Keep containers after stop (default: auto-remove on stop)
  39. #[arg(long)]
  40. keep: bool,
  41. /// Skip pre-serve hooks (cluster-level + recipe-level).
  42. #[arg(long)]
  43. skip_before_serve: bool,
  44. /// Don't follow logs after serving (default: follow).
  45. #[arg(long, action = clap::ArgAction::SetTrue)]
  46. no_follow: bool,
  47. /// Skip the engine-readiness wait after launch (no /v1/models poll).
  48. #[arg(long)]
  49. no_wait: bool,
  50. /// Power on cluster if switch is off (requires HA config).
  51. #[arg(short = 'O', long = "on")]
  52. auto_power_on: bool,
  53. /// Sync Docker image across nodes before starting.
  54. #[arg(long, default_value_t = true)]
  55. sync_image: bool,
  56. /// Explicit source node for image sync (default: auto-detect).
  57. #[arg(long)]
  58. sync_image_source: Option<String>,
  59. /// Build image on this node if not found (requires docker-images/<recipe>/).
  60. #[arg(long)]
  61. build_node: Option<String>,
  62. /// Force rebuild even if checksum matches.
  63. #[arg(long)]
  64. force_build: bool,
  65. /// Run even on occupied nodes (dangerous for GPU recipes: OOM,
  66. /// port conflicts). Skips the occupancy check in cluster selection.
  67. #[arg(long)]
  68. force: bool,
  69. /// Serve single-node on the first free node (overrides recipe `solo`);
  70. /// `--no-solo` forces multi-node.
  71. #[arg(long)]
  72. solo: Option<bool>,
  73. /// Mount a per-node local HF mirror (bare = ~/.cache/hf-models;
  74. /// `--mirror <path>` = that path; first serve copies per node).
  75. /// Mutually exclusive with `--hf-cache` (both pick the HF mount source).
  76. #[arg(long, num_args = 0..=1, default_missing_value = "")]
  77. mirror: Option<String>,
  78. /// Override the host-side HF cache dir for this serve (config
  79. /// `hf_cache_dir`, else `~/.cache/huggingface`). `~` expands to the
  80. /// node's SSH-user home. Mutually exclusive with `--mirror`.
  81. #[arg(long)]
  82. hf_cache: Option<String>,
  83. /// How hard to run the recipe's `[[staging]]` blocks: `auto`
  84. /// (ledger-verified, copy what is new), `skip` (assume staged),
  85. /// `force` (re-verify everything).
  86. #[arg(long)]
  87. stage: Option<String>,
  88. /// Scale the recipe to N nodes (`--fit=N`; bare = whole cluster, TP
  89. /// tracks it; incompatible with --solo).
  90. #[arg(long, num_args = 0..=1, default_missing_value = "")]
  91. fit: Option<String>,
  92. /// `sudo rm -rf` the host-side extra_mounts folders on the launch
  93. /// nodes before containers start (repo-based mounts untouched).
  94. #[arg(long)]
  95. clear: bool,
  96. /// Skip the post-build registry push (only meaningful when `[registry]`
  97. /// is configured and the image needs (re)building).
  98. #[arg(long)]
  99. no_push: bool,
  100. /// Where the image comes from: build from `docker-images/` (default)
  101. /// or pull the prebuilt `{base}/{name}:{checksum}` from `[registry]`
  102. /// (skips the build; the image must already be pushed).
  103. #[arg(long, value_enum, default_value_t = crate::docker::ImageSource::Repo)]
  104. image_source: crate::docker::ImageSource,
  105. },
  106. /// Show container logs
  107. Logs {
  108. #[arg(long, required = true)]
  109. recipe: String,
  110. #[arg(long, action = clap::ArgAction::SetTrue)]
  111. follow: bool,
  112. #[arg(long, default_value = "auto")]
  113. cluster: String,
  114. /// Target a specific node within the cluster (default: head node if following, all nodes if not).
  115. #[arg(long)]
  116. node: Option<String>,
  117. },
  118. /// Stop a cluster
  119. Stop {
  120. #[arg(long, required = true)]
  121. recipe: String,
  122. /// Target specific nodes (repeatable, e.g. --node dgx1 --node dgx2).
  123. #[arg(long)]
  124. node: Vec<String>,
  125. #[arg(long, default_value = "auto")]
  126. cluster: String,
  127. /// Power off cluster after stopping containers (requires HA config).
  128. #[arg(long = "off")]
  129. power_off: bool,
  130. /// Don't wait until the containers are actually stopped and removed
  131. /// (default: wait and verify, up to 60 s per node, so an immediate
  132. /// re-serve on the same nodes doesn't race the dying containers).
  133. #[arg(long)]
  134. no_wait: bool,
  135. },
  136. /// Bench a live serve with tool-eval-bench: reports auto-land in
  137. /// recipes/<lane>/reports/<UTC-date>_<label>.{md,json,log} (seed 42, hardmode, perf).
  138. Bench {
  139. #[arg(long, required = true)]
  140. recipe: String,
  141. #[arg(long, required = true)]
  142. cluster: String,
  143. #[arg(long, required = true)]
  144. label: String,
  145. #[arg(long, default_value = "1,2")]
  146. concurrency: String,
  147. #[arg(long, default_value_t = 42)]
  148. seed: u64,
  149. /// Run scenario evaluations over N concurrent streams (tool
  150. /// `--parallel`). Speeds up the scenario phase ~Nx at the cost of
  151. /// mild engine contention; perf numbers are measured separately.
  152. #[arg(long)]
  153. parallel: Option<u32>,
  154. /// Local tokenizer for llama-benchy (tokenizer.json or a dir with
  155. /// one) — needed when the served id isn't resolvable offline.
  156. #[arg(long)]
  157. tokenizer: Option<String>,
  158. /// Skip the scenario phase — run only the perf micro-benchmarks
  159. /// (use to re-baseline the perf table after a cold first row).
  160. #[arg(long)]
  161. perf_only: bool,
  162. /// Skip the context-depth suite (16K..256K) — run only the main pass
  163. /// (scenarios + typical-depth perf rows).
  164. #[arg(long)]
  165. no_depth: bool,
  166. /// Run tool-eval-bench on a remote host over SSH instead of locally,
  167. /// so the bench client's own network (a WiFi laptop) stays off the
  168. /// measurement path. The tokenizer is uploaded to the host; the
  169. /// report + run package still land locally. The host must have
  170. /// `tool-eval-bench` on PATH.
  171. #[arg(long)]
  172. ssh_host: Option<String>,
  173. },
  174. /// Check cluster health
  175. Health {
  176. #[arg(
  177. long,
  178. help = "Cluster name (e.g. 4nodes-switch). If omitted — checks all."
  179. )]
  180. cluster: Option<String>,
  181. #[arg(long, help = "Show node host keys (for adding to config.toml)")]
  182. fingerprint: bool,
  183. /// Output format (cluster-health mode only; ignored with --fingerprint).
  184. #[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
  185. format: OutputFormat,
  186. },
  187. /// Check and repair a recipe (doctor mode)
  188. Doctor {
  189. #[arg(long, required = true, help = "Recipe name to check")]
  190. recipe: String,
  191. #[arg(
  192. long,
  193. default_value = "auto",
  194. help = "Cluster (if not auto — uses recipe.cluster)"
  195. )]
  196. cluster: String,
  197. #[arg(long, help = "Auto-fix issues (create Dockerfile + build image)")]
  198. repair: bool,
  199. },
  200. /// Publish a recipe (+ image overlay) as a standalone reproducible bundle
  201. Publish(PublishArgs),
  202. /// Browse and install recipes from the public registry
  203. #[command(subcommand)]
  204. Registry(RegistryCommands),
  205. /// Manage tmux sessions (Docker alternative)
  206. #[command(subcommand)]
  207. Tmux(TmuxCommands),
  208. /// Auto-discover host keys and write them into config.toml
  209. InitHostKeys {
  210. #[arg(long, help = "Cluster name (e.g. 4nodes-switch). If omitted — all.")]
  211. cluster: Option<String>,
  212. },
  213. /// Run an arbitrary shell command on a cluster or single node
  214. Exec {
  215. /// Cluster name (mutually exclusive with --recipe).
  216. #[arg(long, conflicts_with = "recipe")]
  217. cluster: Option<String>,
  218. /// Recipe name; resolves to the recipe's pinned cluster.
  219. #[arg(long, conflicts_with = "cluster")]
  220. recipe: Option<String>,
  221. /// Target a specific node within the cluster (default: all).
  222. #[arg(long)]
  223. node: Option<String>,
  224. /// Name of an alias from `[exec_aliases]` in config.toml.
  225. /// Mutually exclusive with a trailing command.
  226. #[arg(long)]
  227. alias: Option<String>,
  228. /// Command to run (everything after `--`).
  229. #[arg(trailing_var_arg = true)]
  230. command: Vec<String>,
  231. },
  232. /// Manage configuration (global config path, init)
  233. #[command(subcommand)]
  234. Config(ConfigCommands),
  235. /// Run-ledger management (per-recipe .lock; offline)
  236. #[command(subcommand)]
  237. Runlock(RunlockCommands),
  238. /// Manage the configured git recipe repos (clone/pull cache, archive
  239. /// registry). `sm121 repos` lists state; `sm121 repos sync [--force]`
  240. /// re-pulls.
  241. Repos(ReposArgs),
  242. /// Archive a recipe repo (it is skipped by sync; state in repos.json)
  243. Archive {
  244. /// The repo URL (a `[[recipe_repos]]` entry)
  245. url: String,
  246. },
  247. /// Un-archive a recipe repo (it takes part in sync again)
  248. Unarchive {
  249. /// The repo URL (a `[[recipe_repos]]` entry)
  250. url: String,
  251. },
  252. /// Move a recipe across recipe repos (git mv + commit + push; the
  253. /// target is updated first, so a failure duplicates, never deletes).
  254. /// Default (or `-d`) prints the exact commands; `--apply` executes.
  255. Promote {
  256. /// Recipe name (its directory under the repo's `recipes/`)
  257. recipe: String,
  258. /// Source repo URL (a `[[recipe_repos]]` entry)
  259. #[arg(long, required = true)]
  260. from: String,
  261. /// Target repo URL (a `[[recipe_repos]]` entry)
  262. #[arg(long, required = true)]
  263. to: String,
  264. /// Execute the move (default: print the plan only)
  265. #[arg(long)]
  266. apply: bool,
  267. },
  268. /// Launch the web dashboard (axum + SSE) on a local port
  269. Web {
  270. #[arg(long, default_value_t = 3100)]
  271. port: u16,
  272. #[arg(long, default_value = "127.0.0.1")]
  273. bind: String,
  274. /// Bearer token for data endpoints (required when --bind is not loopback,
  275. /// unless --insecure)
  276. #[arg(long)]
  277. token: Option<String>,
  278. /// Serve WITHOUT authentication (the token check is skipped entirely —
  279. /// anyone who can reach the port can serve/stop/power the cluster). For
  280. /// isolated LANs where the operator explicitly accepts that.
  281. #[arg(long)]
  282. insecure: bool,
  283. /// Open the browser once the server is up
  284. #[arg(long)]
  285. open: bool,
  286. /// Re-sync the configured `[[recipe_repos]]` in the background every
  287. /// N minutes so the dashboard serves fresh recipes without a restart
  288. /// (0 disables; default 10). The HA add-on relies on this instead of
  289. /// its own entrypoint loop.
  290. #[arg(long, default_value_t = 10)]
  291. repo_sync_interval: u32,
  292. },
  293. /// Table of all tested recipes and their benchmark history
  294. Report {
  295. /// Interactive HTML table via --serve (default: print Markdown)
  296. #[arg(long)]
  297. serve: bool,
  298. /// Port for --serve
  299. #[arg(long, default_value_t = 3101)]
  300. port: u16,
  301. /// Open the browser (implies --serve)
  302. #[arg(long)]
  303. open: bool,
  304. #[arg(long, help = "Archive a recipe (hidden from report output)")]
  305. archive: Option<String>,
  306. #[arg(long, help = "Un-archive a recipe")]
  307. unarchive: Option<String>,
  308. #[arg(long, help = "Show the archived recipes instead of the active ones")]
  309. archived: bool,
  310. },
  311. /// Build Docker image for a recipe on a remote node
  312. Build {
  313. #[arg(long, required = true, help = "Recipe name")]
  314. recipe: String,
  315. #[arg(long, required = true, help = "Node to build on (e.g. dgx1.lan)")]
  316. build_node: String,
  317. #[arg(long, default_value = "auto", help = "Cluster name")]
  318. cluster: String,
  319. /// Force rebuild even if checksum matches.
  320. #[arg(long)]
  321. force_build: bool,
  322. /// Skip the post-build registry push (only meaningful when `[registry]`
  323. /// is configured).
  324. #[arg(long)]
  325. no_push: bool,
  326. /// Where the image comes from: build from `docker-images/` (default)
  327. /// or pull the prebuilt tag from `[registry]` (no build, no push).
  328. #[arg(long, value_enum, default_value_t = crate::docker::ImageSource::Repo)]
  329. image_source: crate::docker::ImageSource,
  330. },
  331. /// Power on/off nodes via Home Assistant (smart plugs / Wake-on-LAN).
  332. Power {
  333. #[command(subcommand)]
  334. action: PowerAction,
  335. },
  336. /// Validate recipes against the launcher contract (build dirs exist,
  337. /// port convention, no floating :latest, floating model revisions,
  338. /// node counts). Errors fail the run (exit 1); archived lanes are
  339. /// grandfathered (their errors downgrade to warnings). Also runs as a
  340. /// `cargo test` gate over the repo's own `recipes/`.
  341. Validate,
  342. }
  343. #[cfg(test)]
  344. #[path = "cli_tests.rs"]
  345. mod tests;