#![deny(clippy::wildcard_imports)] use anyhow::Result; use clap::Parser; fluent_i18n::i18n!("locales", fallback = "en"); mod before_serve; mod cli; mod config; mod docker; mod doctor; mod exec; mod health; mod home_assistant; mod host_keys; mod power; mod pre_serve; mod ssh; mod tmux; use crate::cli::{Cli, Commands}; use crate::config::{ Cluster, Config, default_config_dir, load_recipe, resolve_cluster, select_cluster_by_name, }; use crate::ssh::fetch_host_key; /// Resolve the path to the config file. /// /// Search order: /// 1. `--config ` from CLI (always wins; error if missing) /// 2. `./config.toml` in cwd /// 3. `/config.toml` /// /// Returns an error if nothing is found, with a hint pointing the user /// to copy the example config to one of the two paths. fn resolve_config_path(cli_config: &str) -> std::path::PathBuf { use std::path::PathBuf; let explicit = PathBuf::from(cli_config); // User passed a non-default path: use it as-is. if cli_config != "config.toml" { return explicit; } // Default value (or no flag): try cwd first, then default folder. if explicit.exists() { return explicit; } if let Some(dir) = default_config_dir() { let candidate = dir.join("config.toml"); if candidate.exists() { return candidate; } } // Fall through; the loader will report a clean error. explicit } #[tokio::main] async fn main() -> Result<()> { // Locale: check LANG env var first (macOS sys-locale ignores it). // Normalize "ru_RU.UTF-8" → "ru-RU" for unic-langid. let locale = std::env::var("LANG") .ok() .map(|l| l.split('.').next().unwrap_or(&l).replace('_', "-")); let _ = fluent_i18n::set_locale(locale.as_deref()); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let cli = Cli::parse(); let config_path = resolve_config_path(&cli.config); let raw_config = tokio::fs::read_to_string(&config_path).await?; let config: Config = toml::from_str(&raw_config)?; let dry_run = cli.dry_run; match cli.command { Commands::Serve { recipe, cluster, node, keep, skip_before_serve, follow, } => { let cluster = docker::serve( &config, &recipe, &cluster, node.as_deref(), keep, skip_before_serve, dry_run, ) .await?; if follow && !dry_run { tracing::info!("{}", fluent_i18n::t!("container-log-header")); docker::show_logs(&config, &cluster, &recipe, true, dry_run).await?; } } Commands::Logs { recipe: recipe_name, cluster, follow, } => { let cluster = resolve_cluster_for_op(&config, &recipe_name, &cluster).await?; docker::show_logs(&config, &cluster, &recipe_name, follow, dry_run).await?; } Commands::Stop { recipe: recipe_name, cluster, node, } => { let cluster = resolve_cluster_for_op(&config, &recipe_name, &cluster).await?; docker::stop_cluster(&config, &cluster, &recipe_name, node.as_deref(), dry_run).await?; } Commands::Health { cluster: cluster_name, fingerprint, } => { if fingerprint { let clusters_to_check: Vec = match &cluster_name { Some(name) => vec![select_cluster_by_name(&config, name)?], None => config.clusters.clone(), }; for cluster in &clusters_to_check { println!("\n=== Кластер: {} ===", cluster.name); for node in &cluster.nodes { match fetch_host_key(&config, node).await { Ok(fp) => { println!("{} ({}) -> host_key = \"{}\"", node.name, node.ssh_ip, fp) } Err(e) => println!("{} ({}) -> error: {}", node.name, node.ssh_ip, e), } } } } else { match cluster_name { Some(ref name) => { let cluster = select_cluster_by_name(&config, name)?; docker::health::health_check(&config, &cluster, dry_run).await?; } None => { for cluster in &config.clusters { docker::health::health_check(&config, cluster, dry_run).await?; } } } } } Commands::Doctor { recipe, cluster, repair, } => { doctor::doctor(&config, &recipe, &cluster, repair, dry_run).await?; } Commands::Tmux(tmux_cmd) => { crate::tmux::handle_tmux(&config, tmux_cmd, dry_run).await?; } Commands::InitHostKeys { cluster } => { host_keys::init_host_keys( &config, cluster.as_deref(), config_path.to_str().unwrap_or("config.toml"), ) .await?; } Commands::Exec { cluster, recipe, node, alias, command, } => { let (cluster, _recipe) = exec::resolve_target(&config, cluster.as_deref(), recipe.as_deref()).await?; // Resolve command: alias takes precedence, otherwise join // the trailing positional args into a single command line. let cmd_str = exec::resolve_command(&config, alias.as_deref(), &command)?; exec::run(&config, &cluster, node.as_deref(), &cmd_str, dry_run).await?; } Commands::Power { action } => match action { cli::PowerAction::Off { cluster, node } => { let cluster = select_cluster_by_name(&config, &cluster)?; power::power_off(&config, &cluster, node.as_deref(), dry_run).await?; } cli::PowerAction::On { cluster, node } => { let cluster = select_cluster_by_name(&config, &cluster)?; power::power_on(&config, &cluster, node.as_deref(), dry_run).await?; } cli::PowerAction::Switch { from, to } => { let from = select_cluster_by_name(&config, &from)?; let to = select_cluster_by_name(&config, &to)?; power::power_switch(&config, &from, &to, dry_run).await?; } }, } Ok(()) } /// Resolve a cluster for container operations (stop/logs). /// When `auto`, tries live container label lookup first, then falls back /// to config-based auto-selection (for dry-run / offline scenarios). async fn resolve_cluster_for_op( config: &Config, recipe_name: &str, cluster_name: &str, ) -> Result { if cluster_name != "auto" { let recipe = load_recipe(recipe_name).await?; let (cluster, _) = resolve_cluster(config, &recipe, cluster_name)?; return Ok(cluster); } // Try live container label lookup first if let Ok(c) = docker::resolve_cluster_by_container_label(config, recipe_name).await { return Ok(c.clone()); } // Fall back to config-based auto (dry-run / containers not running) let recipe = load_recipe(recipe_name).await?; let (cluster, _) = resolve_cluster(config, &recipe, "auto")?; Ok(cluster) }