main.rs 13 KB

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