main.rs 16 KB

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