main.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. #![deny(clippy::wildcard_imports)]
  2. // Opt-in heap profiler (the `dhat` feature): a counting global allocator that
  3. // dumps per-callsite retained-heap stats on graceful exit. Debug builds only
  4. // in practice — the normal release build never pays for it.
  5. #[cfg(feature = "dhat")]
  6. #[global_allocator]
  7. static DHAT_ALLOC: dhat::Alloc = dhat::Alloc;
  8. // mimalloc (add-on image builds): trims back to the OS — see the feature
  9. // doc in Cargo.toml for why the Pi needs it.
  10. #[cfg(feature = "mimalloc")]
  11. #[global_allocator]
  12. static MI_ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
  13. use crate::docker::sync;
  14. use anyhow::Result;
  15. use clap::Parser;
  16. fluent_i18n::i18n!("locales", fallback = "en");
  17. mod after_stop;
  18. mod ansi;
  19. mod before_serve;
  20. mod bench;
  21. mod bench_artifacts;
  22. mod bench_depth;
  23. mod bench_io;
  24. mod bench_remote;
  25. mod bench_report;
  26. mod builtin_scripts;
  27. mod cli;
  28. mod cli_sub;
  29. mod cluster_state;
  30. mod cold;
  31. mod config;
  32. mod detect;
  33. mod docker;
  34. mod doctor;
  35. mod ends;
  36. mod engine;
  37. mod exec;
  38. mod gconfig;
  39. mod handlers;
  40. mod health;
  41. mod home_assistant;
  42. mod host_keys;
  43. mod locale;
  44. mod mikrotik;
  45. mod native;
  46. mod orchestrate;
  47. mod power;
  48. mod pre_serve;
  49. mod promote;
  50. mod publish;
  51. mod publish_render;
  52. mod readiness;
  53. mod recipe_repos;
  54. mod registry;
  55. mod report;
  56. mod runlock;
  57. mod ssh;
  58. mod staging;
  59. mod stamp;
  60. mod tmux;
  61. #[cfg(test)]
  62. mod ui_test_lock;
  63. mod validate;
  64. mod web;
  65. use crate::cli::{Cli, Commands};
  66. use crate::config::{Config, DeployTarget, load_recipe};
  67. use crate::handlers::{handle_exec, handle_health, handle_logs, handle_power};
  68. #[tokio::main]
  69. async fn main() -> Result<()> {
  70. // dhat heap profiler (the `dhat` feature): alive for the whole run, so
  71. // the dump at process exit covers every allocation — the retained-heap
  72. // view points at the leak. Writes dhat-heap.json to cwd on exit.
  73. #[cfg(feature = "dhat")]
  74. let _dhat = dhat::Profiler::new_heap();
  75. // Locale: check LANG env var first (macOS sys-locale ignores it).
  76. // Normalize "ru_RU.UTF-8" → "ru-RU" for unic-langid.
  77. let locale = std::env::var("LANG")
  78. .ok()
  79. .map(|l| l.split('.').next().unwrap_or(&l).replace('_', "-"));
  80. let _ = fluent_i18n::set_locale(locale.as_deref());
  81. locale::set_global_locale(locale.clone()); // tokio workers miss the thread-local; spawn_with_locale re-applies
  82. tracing_subscriber::fmt()
  83. .with_env_filter(
  84. tracing_subscriber::EnvFilter::try_from_default_env()
  85. .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
  86. )
  87. .init();
  88. let cli = Cli::parse();
  89. let (config_path, raw_config) = crate::gconfig::read_config(&cli.config).await?;
  90. let mut config: Config = toml::from_str(&raw_config)?;
  91. crate::config::merge_cluster_sources(&mut config).await?;
  92. let dry_run = cli.dry_run;
  93. // Recipe repos: clone/pull the configured git repos into the local
  94. // cache and append their roots to the recipe + image-build search
  95. // paths (first repo wins on a recipe-name collision). Best-effort —
  96. // an unreachable repo degrades to a warning and never blocks a serve
  97. // of a recipe from a reachable repo. Skipped in dry-run (no git I/O).
  98. if !config.recipe_repos.is_empty() && !dry_run {
  99. let roots = crate::recipe_repos::sync_recipe_repos(&config).await;
  100. for r in &roots {
  101. // recipe search: the repo's `recipes/` dir (each `<name>/recipe.toml`);
  102. // image builds: the repo ROOT (its `docker-images/<dir>` build contexts)
  103. config
  104. .recipe_sources
  105. .push(r.join("recipes").display().to_string());
  106. config.docker_image_sources.push(r.display().to_string());
  107. }
  108. }
  109. // `config` subcommands are pure path/file utilities: dispatch them
  110. // before cluster_sources merging so they work even with broken sources.
  111. if let Commands::Config(cmd) = &cli.command {
  112. return crate::gconfig::run(cmd, &cli.config, dry_run);
  113. }
  114. match cli.command {
  115. Commands::Serve {
  116. recipe,
  117. cluster,
  118. node,
  119. keep,
  120. skip_before_serve,
  121. no_follow,
  122. no_wait,
  123. auto_power_on,
  124. sync_image,
  125. sync_image_source,
  126. build_node,
  127. force_build,
  128. force,
  129. solo,
  130. mirror,
  131. hf_cache,
  132. stage,
  133. fit,
  134. cold,
  135. clear,
  136. no_push,
  137. image_source,
  138. } => {
  139. handle_serve(
  140. &config,
  141. &recipe,
  142. &cluster,
  143. node,
  144. docker::ServeOpts {
  145. keep,
  146. skip_before_serve,
  147. no_wait_ready: no_wait,
  148. dry_run,
  149. auto_power_on,
  150. sync_image,
  151. sync_image_source,
  152. no_push,
  153. image_source,
  154. build_node,
  155. force_build,
  156. force,
  157. solo,
  158. mirror,
  159. hf_cache,
  160. stage,
  161. fit,
  162. cold,
  163. clear,
  164. },
  165. !no_follow,
  166. dry_run,
  167. )
  168. .await?;
  169. }
  170. Commands::SyncImage(a) => {
  171. sync::handle_sync_image(&config, &a.recipe, &a.cluster, a.source.as_deref(), dry_run)
  172. .await?;
  173. }
  174. Commands::Build {
  175. recipe,
  176. build_node,
  177. cluster,
  178. force_build,
  179. no_push,
  180. image_source,
  181. } => {
  182. docker::build::handle_build_cmd(
  183. &config,
  184. &docker::build::BuildCmdArgs {
  185. recipe,
  186. build_node,
  187. cluster,
  188. force_build,
  189. no_push,
  190. image_source,
  191. },
  192. dry_run,
  193. )
  194. .await?;
  195. }
  196. Commands::Logs {
  197. recipe: recipe_name,
  198. cluster,
  199. follow,
  200. node,
  201. } => {
  202. handle_logs(&config, &recipe_name, &cluster, follow, node, dry_run).await?;
  203. }
  204. Commands::Stop {
  205. recipe: recipe_name,
  206. cluster,
  207. node,
  208. power_off,
  209. no_wait,
  210. } => {
  211. // Native (DGPP) recipes stop via the engine's own cluster manager,
  212. // not docker labels — route by the recipe's deploy target.
  213. let recipe_obj =
  214. crate::config::load_recipe(&recipe_name, &config.recipe_sources).await?;
  215. if recipe_obj.deploy == crate::config::DeployTarget::Native {
  216. let cname = if !cluster.is_empty() && cluster != "auto" {
  217. cluster.clone()
  218. } else {
  219. recipe_obj.cluster.clone().ok_or_else(|| {
  220. anyhow::anyhow!("native stop needs --cluster (or recipe cluster)")
  221. })?
  222. };
  223. let cl = crate::config::select_cluster_by_name(&config, &cname)?;
  224. native::native_stop(&config, &recipe_obj, &cl, dry_run).await?;
  225. if !dry_run {
  226. crate::runlock::mark_stop(&recipe_name, &config.recipe_sources)?;
  227. }
  228. if power_off {
  229. let names: Vec<String> = cl.nodes.iter().map(|n| n.name.clone()).collect();
  230. crate::power::power_off(&config, &cl, &names, dry_run).await?;
  231. }
  232. } else {
  233. crate::docker::stop::handle(
  234. &config,
  235. &recipe_name,
  236. &cluster,
  237. &node,
  238. !no_wait,
  239. power_off,
  240. dry_run,
  241. )
  242. .await?;
  243. }
  244. }
  245. Commands::Bench {
  246. recipe,
  247. cluster,
  248. node,
  249. label,
  250. concurrency,
  251. seed,
  252. parallel,
  253. perf_only,
  254. depth,
  255. tokenizer,
  256. ssh_host,
  257. no_think,
  258. timeout,
  259. } => {
  260. let args = crate::bench::Args {
  261. recipe,
  262. cluster,
  263. node,
  264. label,
  265. concurrency,
  266. seed,
  267. parallel,
  268. perf_only,
  269. depth,
  270. tokenizer,
  271. ssh_host,
  272. no_think,
  273. timeout,
  274. };
  275. crate::bench::run(&config, &args, dry_run).await?;
  276. }
  277. Commands::Health {
  278. cluster: cluster_name,
  279. fingerprint,
  280. format,
  281. } => {
  282. handle_health(&config, cluster_name, fingerprint, format).await?;
  283. }
  284. Commands::Doctor {
  285. recipe,
  286. cluster,
  287. repair,
  288. } => {
  289. doctor::doctor(&config, &recipe, &cluster, repair, dry_run).await?;
  290. }
  291. Commands::Publish(a) => {
  292. publish::publish(
  293. &config,
  294. &a.recipe,
  295. a.cluster.as_deref(),
  296. a.out,
  297. a.gist,
  298. &publish::RecipeRegistryOpts {
  299. url: a.registry.clone(),
  300. token: a.token.clone(),
  301. },
  302. publish::PushOpts {
  303. push: a.push,
  304. node: a.node.clone(),
  305. force: a.force,
  306. },
  307. )
  308. .await?;
  309. }
  310. Commands::Registry(cmd) => registry::handle(cmd).await?,
  311. Commands::Tmux(tmux_cmd) => {
  312. crate::tmux::handle_tmux(&config, tmux_cmd, dry_run).await?;
  313. }
  314. Commands::InitHostKeys { cluster } => {
  315. host_keys::init_host_keys(
  316. &config,
  317. cluster.as_deref(),
  318. config_path.to_str().unwrap_or("config.toml"),
  319. )
  320. .await?;
  321. }
  322. Commands::Config(_) => unreachable!("dispatched above, before merging"),
  323. Commands::Web {
  324. port,
  325. bind,
  326. token,
  327. open,
  328. insecure,
  329. repo_sync_interval,
  330. } => {
  331. crate::web::run(
  332. config,
  333. port,
  334. bind,
  335. token,
  336. open,
  337. insecure,
  338. repo_sync_interval,
  339. )
  340. .await?;
  341. }
  342. Commands::Exec {
  343. cluster,
  344. recipe,
  345. node,
  346. alias,
  347. command,
  348. } => {
  349. handle_exec(&config, cluster, recipe, node, alias, command, dry_run).await?;
  350. }
  351. Commands::Power { action } => {
  352. handle_power(&config, action, dry_run).await?;
  353. }
  354. Commands::Report {
  355. serve,
  356. port,
  357. open,
  358. archive,
  359. unarchive,
  360. archived,
  361. } => {
  362. crate::report::run(
  363. &config,
  364. serve || open,
  365. port,
  366. open,
  367. archive,
  368. unarchive,
  369. archived,
  370. )
  371. .await?;
  372. }
  373. Commands::Runlock(cmd) => crate::runlock::run(&config, &cmd)?,
  374. Commands::Repos(a) => {
  375. let cmd = a.command.clone().unwrap_or(crate::cli::ReposCommands::List);
  376. crate::recipe_repos::run(&config, &cmd, dry_run).await?
  377. }
  378. Commands::Archive { url } => crate::recipe_repos::archive(&url, false, dry_run)?,
  379. Commands::Unarchive { url } => crate::recipe_repos::archive(&url, true, dry_run)?,
  380. Commands::Promote {
  381. recipe,
  382. from,
  383. to,
  384. apply,
  385. } => crate::promote::run(&config, &recipe, &from, &to, apply, dry_run)?,
  386. Commands::Validate => {
  387. let v = crate::validate::validate_all(&config);
  388. let errs = v
  389. .iter()
  390. .filter(|x| x.level == crate::validate::Level::Error)
  391. .count();
  392. if v.is_empty() {
  393. println!("validate: all recipes pass");
  394. } else {
  395. for x in &v {
  396. let tag = match x.level {
  397. crate::validate::Level::Error => "ERROR",
  398. crate::validate::Level::Warning => "WARN ",
  399. };
  400. println!("{tag} {} [{}]: {}", x.recipe, x.rule, x.message);
  401. }
  402. eprintln!("validate: {errs} error(s), {} warning(s)", v.len() - errs);
  403. if errs > 0 {
  404. std::process::exit(1);
  405. }
  406. }
  407. }
  408. }
  409. Ok(())
  410. }
  411. /// Handle `serve` command: launch inference containers on a cluster.
  412. ///
  413. /// Dispatches to `docker::serve` or `tmux::serve` based on the recipe's
  414. /// `deploy` field. Docker-only CLI opts (sync_image, build_node,
  415. /// force_build, keep, auto_power_on) are ignored with a warning when
  416. /// `deploy = "tmux"`.
  417. pub(crate) async fn handle_serve(
  418. config: &Config,
  419. recipe: &str,
  420. cluster: &str,
  421. node: Option<String>,
  422. opts: docker::ServeOpts,
  423. follow: bool,
  424. dry_run: bool,
  425. ) -> Result<()> {
  426. let recipe_obj = load_recipe(recipe, &config.recipe_sources).await?;
  427. match recipe_obj.deploy {
  428. DeployTarget::Docker => {
  429. let cluster = docker::serve(config, recipe, cluster, node.as_deref(), opts).await?;
  430. if follow && !dry_run {
  431. tracing::info!("{}", fluent_i18n::t!("container-log-header"));
  432. docker::logs::follow_with_liveness(
  433. config,
  434. &cluster,
  435. recipe,
  436. recipe_obj.port,
  437. node.as_deref(),
  438. dry_run,
  439. )
  440. .await?;
  441. }
  442. }
  443. DeployTarget::Native => {
  444. // Head-coordinated native launch (DGPP): the engine's cluster
  445. // manager runs on rank 0 and brings up the peers itself.
  446. native::native_serve(config, recipe, cluster, node.as_deref(), &opts).await?;
  447. if follow && !dry_run {
  448. // DGPP logs live in its own log dir on rank 0 (no docker
  449. // label to tail); point the operator at the log file.
  450. tracing::info!("DGPP serving; engine log on rank 0: ~/dgpp/log/sm121-{recipe}.log");
  451. }
  452. }
  453. DeployTarget::Tmux => {
  454. // Warn about docker-only CLI opts that are silently ignored.
  455. let ignored: Vec<&str> = [
  456. (opts.sync_image, "--sync-image"),
  457. (opts.sync_image_source.is_some(), "--sync-image-source"),
  458. (opts.build_node.is_some(), "--build-node"),
  459. (opts.force_build, "--force-build"),
  460. (opts.keep, "--keep"),
  461. (opts.solo.is_some(), "--solo/--no-solo"),
  462. (opts.mirror.is_some(), "--mirror"),
  463. (opts.fit.is_some(), "--fit"),
  464. ]
  465. .iter()
  466. .filter_map(|(set, flag)| if *set { Some(*flag) } else { None })
  467. .collect();
  468. if !ignored.is_empty() {
  469. tracing::warn!(
  470. "{}",
  471. fluent_i18n::t!("tmux-deploy-ignored-opts", { "opts" => &ignored.join(", ") })
  472. );
  473. }
  474. crate::tmux::serve::tmux_serve(
  475. config,
  476. recipe,
  477. cluster,
  478. opts.skip_before_serve,
  479. dry_run,
  480. opts.auto_power_on,
  481. opts.force,
  482. )
  483. .await?;
  484. if !dry_run {
  485. // no readiness probe for tmux; cluster stays None (unresolved here)
  486. crate::runlock::mark_start(recipe, &config.recipe_sources, None)?;
  487. }
  488. if follow && !dry_run {
  489. tracing::info!(
  490. "{}",
  491. fluent_i18n::t!("tmux-following-logs", { "name" => recipe.to_string() })
  492. );
  493. crate::tmux::ops::tmux_logs(config, recipe, cluster, true, dry_run).await?;
  494. }
  495. }
  496. }
  497. Ok(())
  498. }