cli.rs 17 KB

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