diff --git a/Cargo.lock b/Cargo.lock index 49936d5..82447c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,40 +166,53 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.2.7" +version = "4.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34d21f9bf1b425d2968943631ec91202fe5e837264063503708b83013f8fc938" +checksum = "c27cdf28c0f604ba3f512b0c9a409f8de8513e4816705deb0498b627e7c3a3fd" dependencies = [ "clap_builder", + "clap_derive", + "once_cell", ] [[package]] name = "clap_builder" -version = "4.2.7" +version = "4.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914c8c79fb560f238ef6429439a30023c862f7a28e688c58f7203f12b29970bd" +checksum = "08a9f1ab5e9f01a9b81f202e8562eb9a10de70abf9eaeac1be465c28b75aa4aa" dependencies = [ "anstream", "anstyle", - "bitflags", "clap_lex", "strsim", ] [[package]] name = "clap_complete" -version = "4.2.3" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1594fe2312ec4abf402076e407628f5c313e54c32ade058521df4ee34ecac8a8" +checksum = "5fc443334c81a804575546c5a8a79b4913b50e28d69232903604cada1de817ce" dependencies = [ "clap", ] [[package]] -name = "clap_lex" -version = "0.4.1" +name = "clap_derive" +version = "4.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2dd5a6fe8c6e3502f568a6353e5273bbb15193ad9a89e457b9970798efbea1" +checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.16", +] + +[[package]] +name = "clap_lex" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" [[package]] name = "clicolors-control" diff --git a/Cargo.toml b/Cargo.toml index d358f2e..745af53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ edition = "2021" async-stream = "0.3.5" async-trait = "0.1.68" atty = "0.2" -clap = "4.2.7" -clap_complete = "4.2.3" +clap = { version = "4.3", features = ["derive"] } +clap_complete = "4.3" clicolors-control = "1" console = "0.15.5" const_format = "0.2.30" diff --git a/src/cli.rs b/src/cli.rs index c6246bd..bd33c2d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,15 +2,16 @@ use std::env; -use clap::{ - builder::PossibleValue, value_parser, Arg, ArgAction, ArgMatches, ColorChoice, - Command as ClapCommand, ValueEnum, -}; +use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::Shell; use const_format::{concatcp, formatcp}; use env_logger::fmt::WriteStyle; -use crate::command; +use crate::{ + command::{self, apply::DeployOpts}, + error::ColmenaResult, + nix::{Hive, HivePath}, +}; /// Base URL of the manual, without the trailing slash. const MANUAL_URL_BASE: &str = "https://colmena.cli.rs"; @@ -66,33 +67,11 @@ const HELP_ORDER_FIRST: usize = 100; /// Display order in `--help` for arguments that are not very important. const HELP_ORDER_LOW: usize = 2000; -macro_rules! register_command { - ($module:ident, $app:ident) => { - $app = $app.subcommand(command::$module::subcommand()); - }; -} - -macro_rules! handle_command { - ($module:ident, $matches:ident) => { - if let Some(sub_matches) = $matches.subcommand_matches(stringify!($module)) { - crate::troubleshooter::run_wrapped(&$matches, &sub_matches, command::$module::run) - .await; - return; - } - }; - ($name:expr, $module:ident, $matches:ident) => { - if let Some(sub_matches) = $matches.subcommand_matches($name) { - crate::troubleshooter::run_wrapped(&$matches, &sub_matches, command::$module::run) - .await; - return; - } - }; -} - /// When to display color. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum)] enum ColorWhen { /// Detect automatically. + #[default] Auto, /// Always display colors. @@ -102,157 +81,242 @@ enum ColorWhen { Never, } -impl ValueEnum for ColorWhen { - fn value_variants<'a>() -> &'a [Self] { - &[Self::Auto, Self::Always, Self::Never] - } - - fn to_possible_value<'a>(&self) -> Option { - match self { - Self::Auto => Some(PossibleValue::new("auto")), - Self::Always => Some(PossibleValue::new("always")), - Self::Never => Some(PossibleValue::new("never")), - } +impl std::fmt::Display for ColorWhen { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + }) } } -pub fn build_cli(include_internal: bool) -> ClapCommand { - let version = env!("CARGO_PKG_VERSION"); - let mut app = ClapCommand::new("Colmena") - .bin_name("colmena") - .version(version) - .author("Zhaofeng Li ") - .about("NixOS deployment tool") - .long_about(LONG_ABOUT) - .arg_required_else_help(true) - .arg(Arg::new("config") - .short('f') - .long("config") - .value_name("CONFIG") - .help("Path to a Hive expression, a flake.nix, or a Nix Flake URI") - .long_help(Some(CONFIG_HELP)) - .display_order(HELP_ORDER_FIRST) - - // The default value is a lie (sort of)! - // - // The default behavior is to search upwards from the - // current working directory for a file named "flake.nix" - // or "hive.nix". This behavior is disabled if --config/-f - // is explicitly supplied by the user (occurrences_of > 0). - .default_value("hive.nix") - .global(true)) - .arg(Arg::new("show-trace") - .long("show-trace") - .help("Show debug information for Nix commands") - .long_help("Passes --show-trace to Nix commands") - .global(true) - .num_args(0)) - .arg(Arg::new("impure") - .long("impure") - .help("Allow impure expressions") - .long_help("Passes --impure to Nix commands") - .global(true) - .num_args(0)) - .arg(Arg::new("nix-option") - .long("nix-option") - .help("Passes an arbitrary option to Nix commands") - .long_help(r#"Passes arbitrary options to Nix commands +#[derive(Parser)] +#[command( + name = "Colmena", + bin_name = "colmena", + author = "Zhaofeng Li ", + version = env!("CARGO_PKG_VERSION"), + about = "NixOS deployment tool", + long_about = LONG_ABOUT, +)] +struct Opts { + #[arg( + short = 'f', + long, + value_name = "CONFIG", + help = "Path to a Hive expression, a flake.nix, or a Nix Flake URI", + long_help = CONFIG_HELP, + display_order = HELP_ORDER_FIRST, + global = true, + )] + config: Option, + #[arg( + long, + help = "Show debug information for Nix commands", + long_help = "Passes --show-trace to Nix commands", + global = true + )] + show_trace: bool, + #[arg( + long, + help = "Allow impure expressions", + long_help = "Passes --impure to Nix commands", + global = true + )] + impure: bool, + #[arg( + long, + help = "Passes an arbitrary option to Nix commands", + long_help = r#"Passes arbitrary options to Nix commands This only works when building locally. -"#) - .global(true) - .num_args(2) - .value_names(["NAME", "VALUE"]) - .action(ArgAction::Append)) - .arg(Arg::new("color") - .long("color") - .help("When to colorize the output") - .long_help(r#"When to colorize the output. By default, Colmena enables colorized output when the terminal supports it. +"#, + global = true, + num_args = 2, + value_names = ["NAME", "VALUE"], + )] + nix_option: Vec, + #[arg( + long, + value_name = "WHEN", + default_value_t, + global = true, + display_order = HELP_ORDER_LOW, + help = "When to colorize the output", + long_help = r#"When to colorize the output. By default, Colmena enables colorized output when the terminal supports it. It's also possible to specify the preference using environment variables. See . -"#) - .display_order(HELP_ORDER_LOW) - .value_name("WHEN") - .value_parser(value_parser!(ColorWhen)) - .default_value("auto") - .global(true)); +"#, + )] + color: ColorWhen, + #[command(subcommand)] + command: Command, +} - if include_internal { - app = app.subcommand( - ClapCommand::new("gen-completions") - .about("Generate shell auto-completion files (Internal)") - .hide(true) - .arg( - Arg::new("shell") - .index(1) - .value_parser(value_parser!(Shell)) - .required(true) - .num_args(1), - ), - ); - - // deprecated alias - app = app.subcommand(command::eval::deprecated_alias()); - - #[cfg(debug_assertions)] - register_command!(test_progress, app); - } - - register_command!(apply, app); +#[derive(Subcommand)] +enum Command { + Apply(command::apply::Opts), #[cfg(target_os = "linux")] - register_command!(apply_local, app); - register_command!(build, app); - register_command!(eval, app); - register_command!(upload_keys, app); - register_command!(exec, app); - register_command!(repl, app); - register_command!(nix_info, app); + ApplyLocal(command::apply_local::Opts), + #[command( + about = "Build configurations but not push to remote machines", + long_about = r#"Build configurations but not push to remote machines - // This does _not_ take the --color flag into account (haven't - // parsed yet), only the CLICOLOR environment variable. - if clicolors_control::colors_enabled() { - app.color(ColorChoice::Always) - } else { - app +This subcommand behaves as if you invoked `apply` with the `build` goal."# + )] + Build { + #[command(flatten)] + deploy: DeployOpts, + }, + Eval(command::eval::Opts), + #[command( + about = "Upload keys to remote hosts", + long_about = r#"Upload keys to remote hosts + +This subcommand behaves as if you invoked `apply` with the pseudo `keys` goal."# + )] + UploadKeys { + #[command(flatten)] + deploy: DeployOpts, + }, + Exec(command::exec::Opts), + #[command( + about = "Start an interactive REPL with the complete configuration", + long_about = r#"Start an interactive REPL with the complete configuration + +In the REPL, you can inspect the configuration interactively with tab +completion. The node configurations are accessible under the `nodes` +attribute set."# + )] + Repl, + #[command(about = "Show information about the current Nix installation")] + NixInfo, + #[cfg(debug_assertions)] + #[command(about = "Run progress spinner tests", hide = true)] + TestProgress, + #[command(about = "Generate shell auto-completion files (Internal)", hide = true)] + GenCompletions { + shell: Shell, + }, +} + +async fn get_hive(opts: &Opts) -> ColmenaResult { + let path = match &opts.config { + Some(path) => path.clone(), + None => { + // traverse upwards until we find hive.nix + let mut cur = std::env::current_dir()?; + let mut file_path = None; + + loop { + let flake = cur.join("flake.nix"); + if flake.is_file() { + file_path = Some(flake); + break; + } + + let legacy = cur.join("hive.nix"); + if legacy.is_file() { + file_path = Some(legacy); + break; + } + + match cur.parent() { + Some(parent) => { + cur = parent.to_owned(); + } + None => { + break; + } + } + } + + if file_path.is_none() { + log::error!( + "Could not find `hive.nix` or `flake.nix` in {:?} or any parent directory", + std::env::current_dir()? + ); + } + + HivePath::from_path(file_path.unwrap()).await? + } + }; + + match &path { + HivePath::Legacy(p) => { + log::info!("Using configuration: {}", p.to_string_lossy()); + } + HivePath::Flake(flake) => { + log::info!("Using flake: {}", flake.uri()); + } } + + let mut hive = Hive::new(path).await?; + + if opts.show_trace { + hive.set_show_trace(true); + } + + if opts.impure { + hive.set_impure(true); + } + + for chunks in opts.nix_option.chunks_exact(2) { + let [name, value] = chunks else { + unreachable!() + }; + hive.add_nix_option(name.clone(), value.clone()); + } + + Ok(hive) } pub async fn run() { - let mut app = build_cli(true); - let matches = app.clone().get_matches(); + let opts = Opts::parse(); - set_color_pref(matches.get_one("color").unwrap()); + set_color_pref(&opts.color); init_logging(); - handle_command!(apply, matches); - #[cfg(target_os = "linux")] - handle_command!("apply-local", apply_local, matches); - handle_command!(build, matches); - handle_command!(eval, matches); - handle_command!("upload-keys", upload_keys, matches); - handle_command!(exec, matches); - handle_command!(repl, matches); - handle_command!("nix-info", nix_info, matches); + let hive = get_hive(&opts).await.expect("Failed to get flake or hive"); - #[cfg(debug_assertions)] - handle_command!("test-progress", test_progress, matches); + use crate::troubleshooter::run_wrapped as r; - if let Some(args) = matches.subcommand_matches("gen-completions") { - return gen_completions(args); + match opts.command { + Command::Apply(args) => r(command::apply::run(hive, args), opts.config).await, + #[cfg(target_os = "linux")] + Command::ApplyLocal(args) => r(command::apply_local::run(hive, args), opts.config).await, + Command::Eval(args) => r(command::eval::run(hive, args), opts.config).await, + Command::Exec(args) => r(command::exec::run(hive, args), opts.config).await, + Command::NixInfo => r(command::nix_info::run(), opts.config).await, + Command::Repl => r(command::repl::run(hive), opts.config).await, + #[cfg(debug_assertions)] + Command::TestProgress => r(command::test_progress::run(), opts.config).await, + Command::Build { deploy } => { + let args = command::apply::Opts { + deploy, + goal: crate::nix::Goal::Build, + }; + r(command::apply::run(hive, args), opts.config).await + } + Command::UploadKeys { deploy } => { + let args = command::apply::Opts { + deploy, + goal: crate::nix::Goal::UploadKeys, + }; + r(command::apply::run(hive, args), opts.config).await + } + Command::GenCompletions { shell } => print_completions(shell, &mut Opts::command()), } - - // deprecated alias - handle_command!("introspect", eval, matches); - - app.print_long_help().unwrap(); - println!(); } -fn gen_completions(args: &ArgMatches) { - let mut app = build_cli(false); - let shell = args.get_one::("shell").unwrap().to_owned(); - - clap_complete::generate(shell, &mut app, "colmena", &mut std::io::stdout()); +fn print_completions(shell: Shell, cmd: &mut clap::Command) { + clap_complete::generate( + shell, + cmd, + cmd.get_name().to_string(), + &mut std::io::stdout(), + ); } fn set_color_pref(when: &ColorWhen) { @@ -281,13 +345,3 @@ fn init_logging() { .write_style(style) .init(); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cli_debug_assert() { - build_cli(true).debug_assert() - } -} diff --git a/src/command/apply.rs b/src/command/apply.rs index 1bcf110..33395db 100644 --- a/src/command/apply.rs +++ b/src/command/apply.rs @@ -1,134 +1,128 @@ use std::env; use std::path::PathBuf; -use clap::{ - builder::{ArgPredicate, PossibleValuesParser, ValueParser}, - value_parser, Arg, ArgMatches, Command as ClapCommand, -}; +use clap::{builder::ArgPredicate, Args}; use crate::error::ColmenaError; -use crate::nix::deployment::{ - Deployment, EvaluationNodeLimit, EvaluatorType, Goal, Options, ParallelismLimit, +use crate::nix::{ + deployment::{Deployment, EvaluationNodeLimit, EvaluatorType, Goal, Options, ParallelismLimit}, + node_filter::NodeFilterOpts, + Hive, }; -use crate::nix::NodeFilter; use crate::progress::SimpleProgressOutput; -use crate::util; -pub fn register_deploy_args(command: ClapCommand) -> ClapCommand { - command - .arg(Arg::new("eval-node-limit") - .long("eval-node-limit") - .value_name("LIMIT") - .help("Evaluation node limit") - .long_help(r#"Limits the maximum number of hosts to be evaluated at once. +#[derive(Debug, Args)] +pub struct DeployOpts { + #[arg( + value_name = "LIMIT", + default_value_t, + long, + help = "Evaluation node limit", + long_help = r#"Limits the maximum number of hosts to be evaluated at once. The evaluation process is RAM-intensive. The default behavior is to limit the maximum number of host evaluated at the same time based on naive heuristics. Set to 0 to disable the limit. -"#) - .default_value("auto") - .num_args(1) - .value_parser(ValueParser::new(|s: &str| -> Result { - if s == "auto" { - return Ok(EvaluationNodeLimit::Heuristic); - } +"# + )] + eval_node_limit: EvaluationNodeLimit, + #[arg( + value_name = "LIMIT", + default_value_t = 10, + long, + short, + help = "Deploy parallelism limit", + long_help = r#"Limits the maximum number of hosts to be deployed in parallel. - match s.parse::() { - Ok(0) => Ok(EvaluationNodeLimit::None), - Ok(n) => Ok(EvaluationNodeLimit::Manual(n)), - Err(_) => Err(String::from("The value must be a valid number")), - } - }))) - .arg(Arg::new("parallel") - .short('p') - .long("parallel") - .value_name("LIMIT") - .help("Deploy parallelism limit") - .long_help(r#"Limits the maximum number of hosts to be deployed in parallel. - -Set to 0 to disable parallemism limit. -"#) - .default_value("10") - .num_args(1) - .value_parser(value_parser!(usize))) - .arg(Arg::new("keep-result") - .long("keep-result") - .help("Create GC roots for built profiles") - .long_help(r#"Create GC roots for built profiles. +Set to 0 to disable parallelism limit. +"# + )] + parallel: usize, + #[arg( + long, + help = "Create GC roots for built profiles", + long_help = r#"Create GC roots for built profiles. The built system profiles will be added as GC roots so that they will not be removed by the garbage collector. The links will be created under .gcroots in the directory the Hive configuration is located. -"#) - .num_args(0)) - .arg(Arg::new("verbose") - .short('v') - .long("verbose") - .help("Be verbose") - .long_help("Deactivates the progress spinner and prints every line of output.") - .num_args(0)) - .arg(Arg::new("no-keys") - .long("no-keys") - .help("Do not upload keys") - .long_help(r#"Do not upload secret keys set in `deployment.keys`. +"# + )] + keep_result: bool, + #[arg( + short, + long, + help = "Be verbose", + long_help = "Deactivates the progress spinner and prints every line of output." + )] + verbose: bool, + #[arg( + long, + help = "Do not upload keys", + long_help = r#"Do not upload secret keys set in `deployment.keys`. By default, Colmena will upload keys set in `deployment.keys` before deploying the new profile on a node. To upload keys without building or deploying the rest of the configuration, use `colmena upload-keys`. -"#) - .num_args(0)) - .arg(Arg::new("reboot") - .long("reboot") - .help("Reboot nodes after activation") - .long_help("Reboots nodes after activation and waits for them to come back up.") - .num_args(0)) - .arg(Arg::new("no-substitute") - .long("no-substitute") - .alias("no-substitutes") - .help("Do not use substitutes") - .long_help("Disables the use of substituters when copying closures to the remote host.") - .num_args(0)) - .arg(Arg::new("no-gzip") - .long("no-gzip") - .help("Do not use gzip") - .long_help("Disables the use of gzip when copying closures to the remote host.") - .num_args(0)) - .arg(Arg::new("build-on-target") - .long("build-on-target") - .help("Build the system profiles on the target nodes") - .long_help(r#"Build the system profiles on the target nodes themselves. +"# + )] + no_keys: bool, + #[arg( + long, + help = "Reboot nodes after activation", + long_help = "Reboots nodes after activation and waits for them to come back up." + )] + reboot: bool, + #[arg( + long, + alias = "no-substitutes", + help = "Do not use substitutes", + long_help = "Disables the use of substituters when copying closures to the remote host." + )] + no_substitute: bool, + #[arg( + long, + help = "Do not use gzip", + long_help = "Disables the use of gzip when copying closures to the remote host." + )] + no_gzip: bool, + #[arg( + long, + help = "Build the system profiles on the target nodes", + long_help = r#"Build the system profiles on the target nodes themselves. If enabled, the system profiles will be built on the target nodes themselves, not on the host running Colmena itself. This overrides per-node perferences set in `deployment.buildOnTarget`. To temporarily disable remote build on all nodes, use `--no-build-on-target`. -"#) - .num_args(0)) - .arg(Arg::new("no-build-on-target") - .long("no-build-on-target") - .hide(true) - .num_args(0)) - .arg(Arg::new("force-replace-unknown-profiles") - .long("force-replace-unknown-profiles") - .help("Ignore all targeted nodes deployment.replaceUnknownProfiles setting") - .long_help(r#"If `deployment.replaceUnknownProfiles` is set for a target, using this switch -will treat deployment.replaceUnknownProfiles as though it was set true and perform unknown profile replacement."#) - .num_args(0)) - .arg(Arg::new("evaluator") - .long("evaluator") - .help("The evaluator to use (experimental)") - .long_help(r#"If set to `chunked` (default), evaluation of nodes will happen in batches. If set to `streaming`, the experimental streaming evaluator (nix-eval-jobs) will be used and nodes will be evaluated in parallel. +"# + )] + build_on_target: bool, + #[arg(long, hide = true)] + no_build_on_target: bool, + #[arg( + long, + help = "Ignore all targeted nodes deployment.replaceUnknownProfiles setting", + long_help = r#"If `deployment.replaceUnknownProfiles` is set for a target, using this switch +will treat deployment.replaceUnknownProfiles as though it was set true and perform unknown profile replacement."# + )] + force_replace_unknown_profiles: bool, + #[arg( + long, + default_value_t, + help = "The evaluator to use (experimental)", + long_help = r#"If set to `chunked` (default), evaluation of nodes will happen in batches. If set to `streaming`, the experimental streaming evaluator (nix-eval-jobs) will be used and nodes will be evaluated in parallel. -This is an experimental feature."#) - .default_value("chunked") - .value_parser(value_parser!(EvaluatorType))) +This is an experimental feature."# + )] + evaluator: EvaluatorType, + #[command(flatten)] + node_filter: NodeFilterOpts, } -pub fn subcommand() -> ClapCommand { - let command = ClapCommand::new("apply") - .about("Apply configurations on remote machines") - .arg( - Arg::new("goal") - .help("Deployment goal") - .long_help( - r#"The goal of the deployment. +#[derive(Debug, Args)] +#[command(name = "apply", about = "Apply configurations on remote machines")] +pub struct Opts { + #[arg( + help = "Deployment goal", + long_help = r#"The goal of the deployment. Same as the targets for switch-to-configuration, with the following extra pseudo-goals: @@ -138,42 +132,38 @@ Same as the targets for switch-to-configuration, with the following extra pseudo `switch` is the default goal unless `--reboot` is passed, in which case `boot` is the default. "#, - ) - .default_value("switch") - .default_value_if("reboot", ArgPredicate::IsPresent, Some("boot")) - .default_value("switch") - .index(1) - .value_parser(PossibleValuesParser::new([ - "build", - "push", - "switch", - "boot", - "test", - "dry-activate", - "keys", - ])), - ); - let command = register_deploy_args(command); - - util::register_selector_args(command) + default_value_t, + default_value_if("reboot", ArgPredicate::IsPresent, Some("boot")) + )] + pub goal: Goal, + #[command(flatten)] + pub deploy: DeployOpts, } -pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<(), ColmenaError> { - let hive = util::hive_from_args(local_args).await?; - +pub async fn run(hive: Hive, opts: Opts) -> Result<(), ColmenaError> { let ssh_config = env::var("SSH_CONFIG_FILE").ok().map(PathBuf::from); - // FIXME: Just get_one:: - let goal_arg = local_args.get_one::("goal").unwrap(); - let goal = Goal::from_str(goal_arg).unwrap(); + let Opts { + goal, + deploy: + DeployOpts { + eval_node_limit, + parallel, + keep_result, + verbose, + no_keys, + reboot, + no_substitute, + no_gzip, + build_on_target, + no_build_on_target, + force_replace_unknown_profiles, + evaluator, + node_filter, + }, + } = opts; - // FIXME: Just get_one:: - let filter = local_args - .get_one::("on") - .map(NodeFilter::new) - .transpose()?; - - if filter.is_none() && goal != Goal::Build { + if node_filter.on.is_none() && goal != Goal::Build { // User did not specify node, we should check meta and see rules let meta = hive.get_meta_config().await?; if !meta.allow_apply_all { @@ -184,11 +174,15 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( } let targets = hive - .select_nodes(filter, ssh_config, goal.requires_target_host()) + .select_nodes( + node_filter.on.clone(), + ssh_config, + goal.requires_target_host(), + ) .await?; let n_targets = targets.len(); - let verbose = local_args.get_flag("verbose") || goal == Goal::DryActivate; + let verbose = verbose || goal == Goal::DryActivate; let mut output = SimpleProgressOutput::new(verbose); let progress = output.get_sender(); @@ -197,27 +191,20 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( // FIXME: Configure limits let options = { let mut options = Options::default(); - options.set_substituters_push(!local_args.get_flag("no-substitute")); - options.set_gzip(!local_args.get_flag("no-gzip")); - options.set_upload_keys(!local_args.get_flag("no-keys")); - options.set_reboot(local_args.get_flag("reboot")); - options.set_force_replace_unknown_profiles( - local_args.get_flag("force-replace-unknown-profiles"), - ); - options.set_evaluator( - local_args - .get_one::("evaluator") - .unwrap() - .to_owned(), - ); + options.set_substituters_push(!no_substitute); + options.set_gzip(!no_gzip); + options.set_upload_keys(!no_keys); + options.set_reboot(reboot); + options.set_force_replace_unknown_profiles(force_replace_unknown_profiles); + options.set_evaluator(evaluator); - if local_args.get_flag("keep-result") { + if keep_result { options.set_create_gc_roots(true); } - if local_args.get_flag("no-build-on-target") { + if no_build_on_target { options.set_force_build_on_target(false); - } else if local_args.get_flag("build-on-target") { + } else if build_on_target { options.set_force_build_on_target(true); } @@ -226,7 +213,7 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( deployment.set_options(options); - if local_args.get_flag("no-keys") && goal == Goal::UploadKeys { + if no_keys && goal == Goal::UploadKeys { log::error!("--no-keys cannot be used when the goal is to upload keys"); quit::with_code(1); } @@ -234,23 +221,17 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( let parallelism_limit = { let mut limit = ParallelismLimit::default(); limit.set_apply_limit({ - let limit = local_args.get_one::("parallel").unwrap().to_owned(); - if limit == 0 { + if parallel == 0 { n_targets } else { - limit + parallel } }); limit }; - let evaluation_node_limit = local_args - .get_one::("eval-node-limit") - .unwrap() - .to_owned(); - deployment.set_parallelism_limit(parallelism_limit); - deployment.set_evaluation_node_limit(evaluation_node_limit); + deployment.set_evaluation_node_limit(eval_node_limit); let (deployment, output) = tokio::join!(deployment.execute(), output.run_until_completion(),); diff --git a/src/command/apply_local.rs b/src/command/apply_local.rs index b712a7e..9ec38dc 100644 --- a/src/command/apply_local.rs +++ b/src/command/apply_local.rs @@ -1,66 +1,70 @@ use regex::Regex; use std::collections::HashMap; -use clap::{builder::PossibleValuesParser, Arg, ArgMatches, Command as ClapCommand}; +use clap::Args; use tokio::fs; use crate::error::ColmenaError; + use crate::nix::deployment::{Deployment, Goal, Options, TargetNode}; +use crate::nix::Hive; use crate::nix::{host::Local as LocalHost, NodeName}; use crate::progress::SimpleProgressOutput; -use crate::util; -pub fn subcommand() -> ClapCommand { - ClapCommand::new("apply-local") - .about("Apply configurations on the local machine") - .arg(Arg::new("goal") - .help("Deployment goal") - .long_help("Same as the targets for switch-to-configuration.\n\"push\" is noop in apply-local.") - .default_value("switch") - .index(1) - .value_parser(PossibleValuesParser::new([ - "push", - "switch", - "boot", - "test", - "dry-activate", - "keys", - ]))) - .arg(Arg::new("sudo") - .long("sudo") - .help("Attempt to escalate privileges if not run as root") - .num_args(0)) - .arg(Arg::new("verbose") - .short('v') - .long("verbose") - .help("Be verbose") - .long_help("Deactivates the progress spinner and prints every line of output.") - .num_args(0)) - .arg(Arg::new("no-keys") - .long("no-keys") - .help("Do not deploy keys") - .long_help(r#"Do not deploy secret keys set in `deployment.keys`. +#[derive(Debug, Args)] +#[command( + name = "apply-local", + about = "Apply configurations on the local machine" +)] +pub struct Opts { + #[arg( + help = "Deployment goal", + value_name = "GOAL", + default_value_t, + long_help = "Same as the targets for switch-to-configuration.\n\"push\" is noop in apply-local." + )] + goal: Goal, + #[arg(long, help = "Attempt to escalate privileges if not run as root")] + sudo: bool, + #[arg( + short, + long, + help = "Be verbose", + long_help = "Deactivates the progress spinner and prints every line of output." + )] + verbose: bool, + #[arg( + long, + help = "Do not deploy keys", + long_help = r#"Do not deploy secret keys set in `deployment.keys`. By default, Colmena will deploy keys set in `deployment.keys` before activating the profile on this host. -"#) - .num_args(0)) - .arg(Arg::new("node") - .long("node") - .value_name("NODE") - .help("Override the node name to use") - .num_args(1)) - - // Removed - .arg(Arg::new("sudo-command") - .long("sudo-command") - .value_name("COMMAND") - .help("Removed: Configure deployment.privilegeEscalationCommand in node configuration") - .hide(true) - .num_args(1)) +"# + )] + no_keys: bool, + #[arg(long, help = "Override the node name to use")] + node: Option, + #[arg( + long, + value_name = "COMMAND", + hide = true, + help = "Removed: Configure deployment.privilegeEscalationCommand in node configuration" + )] + sudo_command: Option, } -pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<(), ColmenaError> { - if local_args.contains_id("sudo-command") { +pub async fn run( + hive: Hive, + Opts { + goal, + sudo, + verbose, + no_keys, + node, + sudo_command, + }: Opts, +) -> Result<(), ColmenaError> { + if sudo_command.is_some() { log::error!("--sudo-command has been removed. Please configure it in deployment.privilegeEscalationCommand in the node configuration."); quit::with_code(1); } @@ -77,31 +81,22 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( quit::with_code(5); } - let escalate_privileges = local_args.get_flag("sudo"); - let verbose = local_args.get_flag("verbose") || escalate_privileges; // cannot use spinners with interactive sudo + let verbose = verbose || sudo; // cannot use spinners with interactive sudo { let euid: u32 = unsafe { libc::geteuid() }; - if euid != 0 && !escalate_privileges { + if euid != 0 && !sudo { log::warn!("Colmena was not started by root. This is probably not going to work."); log::warn!("Hint: Add the --sudo flag."); } } - let hive = util::hive_from_args(local_args).await.unwrap(); - let hostname = { - let s = if local_args.contains_id("node") { - local_args.get_one::("node").unwrap().to_owned() - } else { - hostname::get() - .expect("Could not get hostname") - .to_string_lossy() - .into_owned() - }; - - NodeName::new(s)? - }; - let goal = Goal::from_str(local_args.get_one::("goal").unwrap()).unwrap(); + let hostname = NodeName::new(node.unwrap_or_else(|| { + hostname::get() + .expect("Could not get hostname") + .to_string_lossy() + .into_owned() + }))?; let target = { if let Some(info) = hive.deployment_info_single(&hostname).await.unwrap() { @@ -115,7 +110,7 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( quit::with_code(2); } let mut host = LocalHost::new(nix_options); - if escalate_privileges { + if sudo { let command = info.privilege_escalation_command().to_owned(); host.set_privilege_escalation_command(Some(command)); } @@ -140,13 +135,13 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( let options = { let mut options = Options::default(); - options.set_upload_keys(!local_args.get_flag("no-keys")); + options.set_upload_keys(!no_keys); options }; deployment.set_options(options); - let (deployment, output) = tokio::join!(deployment.execute(), output.run_until_completion(),); + let (deployment, output) = tokio::join!(deployment.execute(), output.run_until_completion()); deployment?; output?; diff --git a/src/command/build.rs b/src/command/build.rs deleted file mode 100644 index 286b42b..0000000 --- a/src/command/build.rs +++ /dev/null @@ -1,27 +0,0 @@ -use clap::{builder::PossibleValuesParser, Arg, Command as ClapCommand}; - -use crate::util; - -use super::apply; -pub use super::apply::run; - -pub fn subcommand() -> ClapCommand { - let command = ClapCommand::new("build") - .about("Build configurations but not push to remote machines") - .long_about( - r#"Build configurations but not push to remote machines - -This subcommand behaves as if you invoked `apply` with the `build` goal."#, - ) - .arg( - Arg::new("goal") - .hide(true) - .default_value("build") - .value_parser(PossibleValuesParser::new(["build"])) - .num_args(1), - ); - - let command = apply::register_deploy_args(command); - - util::register_selector_args(command) -} diff --git a/src/command/eval.rs b/src/command/eval.rs index 0fc271a..3a7259f 100644 --- a/src/command/eval.rs +++ b/src/command/eval.rs @@ -1,80 +1,62 @@ use std::path::PathBuf; -use clap::{value_parser, Arg, ArgMatches, Command as ClapCommand}; +use clap::Args; use crate::error::ColmenaError; -use crate::util; +use crate::nix::Hive; -pub fn subcommand() -> ClapCommand { - subcommand_gen("eval") -} - -pub fn deprecated_alias() -> ClapCommand { - subcommand_gen("introspect").hide(true) -} - -fn subcommand_gen(name: &'static str) -> ClapCommand { - ClapCommand::new(name) - .about("Evaluate an expression using the complete configuration") - .long_about(r#"Evaluate an expression using the complete configuration +#[derive(Debug, Args)] +#[command( + name = "eval", + alias = "introspect", + about = "Evaluate an expression using the complete configuration", + long_about = r#"Evaluate an expression using the complete configuration Your expression should take an attribute set with keys `pkgs`, `lib` and `nodes` (like a NixOS module) and return a JSON-serializable value. For example, to retrieve the configuration of one node, you may write something like: { nodes, ... }: nodes.node-a.config.networking.hostName -"#) - .arg(Arg::new("expression_file") - .index(1) - .value_name("FILE") - .help("The .nix file containing the expression") - .num_args(1) - .value_parser(value_parser!(PathBuf))) - .arg(Arg::new("expression") - .short('E') - .value_name("EXPRESSION") - .help("The Nix expression") - .num_args(1)) - .arg(Arg::new("instantiate") - .long("instantiate") - .help("Actually instantiate the expression") - .num_args(0)) +"# +)] +pub struct Opts { + #[arg(short = 'E', value_name = "EXPRESSION", help = "The Nix expression")] + expression: Option, + #[arg(long, help = "Actually instantiate the expression")] + instantiate: bool, + #[arg( + value_name = "FILE", + help = "The .nix file containing the expression", + conflicts_with("expression") + )] + expression_file: Option, } -pub async fn run(global_args: &ArgMatches, local_args: &ArgMatches) -> Result<(), ColmenaError> { - if let Some("introspect") = global_args.subcommand_name() { - log::warn!( - "`colmena introspect` has been renamed to `colmena eval`. Please update your scripts." - ); - } +pub async fn run( + hive: Hive, + Opts { + expression, + instantiate, + expression_file, + }: Opts, +) -> Result<(), ColmenaError> { + let expression = expression_file + .map(|path| { + format!( + "import {}", + path.canonicalize() + .expect("Could not generate absolute path to expression file.") + .to_str() + .unwrap() + ) + }) + .or(expression); - let hive = util::hive_from_args(local_args).await?; - - if !(local_args.contains_id("expression") ^ local_args.contains_id("expression_file")) { - log::error!("Either an expression (-E) or a .nix file containing an expression should be specified, not both."); + let Some(expression) = expression else { + log::error!("Provide either an expression (-E) or a .nix file containing an expression."); quit::with_code(1); - } - - let expression = if local_args.contains_id("expression") { - local_args - .get_one::("expression") - .unwrap() - .to_owned() - } else { - let path = local_args - .get_one::("expression_file") - .unwrap() - .to_owned(); - format!( - "import {}", - path.canonicalize() - .expect("Could not generate absolute path to expression file.") - .to_str() - .unwrap() - ) }; - let instantiate = local_args.get_flag("instantiate"); let result = hive.introspect(expression, instantiate).await?; if instantiate { diff --git a/src/command/exec.rs b/src/command/exec.rs index b723f6b..a2b8d38 100644 --- a/src/command/exec.rs +++ b/src/command/exec.rs @@ -2,94 +2,78 @@ use std::env; use std::path::PathBuf; use std::sync::Arc; -use clap::{value_parser, Arg, ArgMatches, Command as ClapCommand}; +use clap::Args; use futures::future::join_all; use tokio::sync::Semaphore; use crate::error::ColmenaError; use crate::job::{JobMonitor, JobState, JobType}; -use crate::nix::NodeFilter; +use crate::nix::node_filter::NodeFilterOpts; +use crate::nix::Hive; use crate::progress::SimpleProgressOutput; use crate::util; -pub fn subcommand() -> ClapCommand { - let command = ClapCommand::new("exec") - .about("Run a command on remote machines") - .arg( - Arg::new("parallel") - .short('p') - .long("parallel") - .value_name("LIMIT") - .help("Deploy parallelism limit") - .long_help( - r#"Limits the maximum number of hosts to run the command in parallel. +#[derive(Debug, Args)] +#[command(name = "exec", about = "Run a command on remote machines")] +pub struct Opts { + #[arg( + short, + long, + default_value_t = 0, + value_name = "LIMIT", + help = "Deploy parallelism limit", + long_help = r#"Limits the maximum number of hosts to run the command in parallel. In `colmena exec`, the parallelism limit is disabled (0) by default. -"#, - ) - .default_value("0") - .num_args(1) - .value_parser(value_parser!(usize)), - ) - .arg( - Arg::new("verbose") - .short('v') - .long("verbose") - .help("Be verbose") - .long_help("Deactivates the progress spinner and prints every line of output.") - .num_args(0), - ) - .arg( - Arg::new("command") - .value_name("COMMAND") - .trailing_var_arg(true) - .help("Command") - .required(true) - .num_args(1..) - .long_help( - r#"Command to run +"# + )] + parallel: usize, + #[arg( + short, + long, + help = "Be verbose", + long_help = "Deactivates the progress spinner and prints every line of output." + )] + verbose: bool, + #[command(flatten)] + nodes: NodeFilterOpts, + #[arg( + trailing_var_arg = true, + required = true, + value_name = "COMMAND", + help = "Command", + long_help = r#"Command to run It's recommended to use -- to separate Colmena options from the command to run. For example: colmena exec --on @routers -- tcpdump -vni any ip[9] == 89 -"#, - ), - ); - - util::register_selector_args(command) +"# + )] + command: Vec, } -pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<(), ColmenaError> { - let hive = util::hive_from_args(local_args).await?; +pub async fn run( + hive: Hive, + Opts { + parallel, + verbose, + nodes, + command, + }: Opts, +) -> Result<(), ColmenaError> { let ssh_config = env::var("SSH_CONFIG_FILE").ok().map(PathBuf::from); - // FIXME: Just get_one:: - let filter = local_args - .get_one::("on") - .map(NodeFilter::new) - .transpose()?; + let mut targets = hive.select_nodes(nodes.on, ssh_config, true).await?; - let mut targets = hive.select_nodes(filter, ssh_config, true).await?; - - let parallel_sp = Arc::new({ - let limit = local_args.get_one::("parallel").unwrap().to_owned(); - - if limit > 0 { - Some(Semaphore::new(limit)) - } else { - None - } + let parallel_sp = Arc::new(if parallel > 0 { + Some(Semaphore::new(parallel)) + } else { + None }); - let command: Arc> = Arc::new( - local_args - .get_many::("command") - .unwrap() - .cloned() - .collect(), - ); + let command = Arc::new(command); - let mut output = SimpleProgressOutput::new(local_args.get_flag("verbose")); + let mut output = SimpleProgressOutput::new(verbose); let (mut monitor, meta) = JobMonitor::new(output.get_sender()); @@ -102,7 +86,7 @@ pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<( for (name, target) in targets.drain() { let parallel_sp = parallel_sp.clone(); - let command = command.clone(); + let command = Arc::clone(&command); let mut host = target.into_host().unwrap(); diff --git a/src/command/mod.rs b/src/command/mod.rs index d982081..cc6c8cb 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -1,10 +1,8 @@ pub mod apply; -pub mod build; pub mod eval; pub mod exec; pub mod nix_info; pub mod repl; -pub mod upload_keys; #[cfg(target_os = "linux")] pub mod apply_local; diff --git a/src/command/nix_info.rs b/src/command/nix_info.rs index a4c0643..c63c9f2 100644 --- a/src/command/nix_info.rs +++ b/src/command/nix_info.rs @@ -1,14 +1,8 @@ -use clap::{ArgMatches, Command as ClapCommand}; - -use crate::error::ColmenaError; +use crate::error::ColmenaResult; use crate::nix::evaluator::nix_eval_jobs::get_pinned_nix_eval_jobs; use crate::nix::NixCheck; -pub fn subcommand() -> ClapCommand { - ClapCommand::new("nix-info").about("Show information about the current Nix installation") -} - -pub async fn run(_global_args: &ArgMatches, _local_args: &ArgMatches) -> Result<(), ColmenaError> { +pub async fn run() -> ColmenaResult<()> { let check = NixCheck::detect().await; check.print_version_info(); check.print_flakes_info(false); diff --git a/src/command/repl.rs b/src/command/repl.rs index 5e1e93e..28e6e45 100644 --- a/src/command/repl.rs +++ b/src/command/repl.rs @@ -1,31 +1,16 @@ use std::io::Write; -use clap::{ArgMatches, Command as ClapCommand}; use tempfile::Builder as TempFileBuilder; use tokio::process::Command; -use crate::error::ColmenaError; +use crate::error::ColmenaResult; use crate::nix::info::NixCheck; -use crate::util; +use crate::nix::Hive; -pub fn subcommand() -> ClapCommand { - ClapCommand::new("repl") - .about("Start an interactive REPL with the complete configuration") - .long_about( - r#"Start an interactive REPL with the complete configuration - -In the REPL, you can inspect the configuration interactively with tab -completion. The node configurations are accessible under the `nodes` -attribute set."#, - ) -} - -pub async fn run(_global_args: &ArgMatches, local_args: &ArgMatches) -> Result<(), ColmenaError> { +pub async fn run(hive: Hive) -> ColmenaResult<()> { let nix_check = NixCheck::detect().await; let nix_version = nix_check.version().expect("Could not detect Nix version"); - let hive = util::hive_from_args(local_args).await?; - let expr = hive.get_repl_expression(); let mut expr_file = TempFileBuilder::new() diff --git a/src/command/test_progress.rs b/src/command/test_progress.rs index df21980..9cba556 100644 --- a/src/command/test_progress.rs +++ b/src/command/test_progress.rs @@ -1,6 +1,5 @@ use std::time::Duration; -use clap::{ArgMatches, Command as ClapCommand}; use tokio::time; use crate::error::{ColmenaError, ColmenaResult}; @@ -14,13 +13,7 @@ macro_rules! node { }; } -pub fn subcommand() -> ClapCommand { - ClapCommand::new("test-progress") - .about("Run progress spinner tests") - .hide(true) -} - -pub async fn run(_global_args: &ArgMatches, _local_args: &ArgMatches) -> Result<(), ColmenaError> { +pub async fn run() -> Result<(), ColmenaError> { let mut output = SpinnerOutput::new(); let (monitor, meta) = JobMonitor::new(output.get_sender()); diff --git a/src/command/upload_keys.rs b/src/command/upload_keys.rs deleted file mode 100644 index 28d9944..0000000 --- a/src/command/upload_keys.rs +++ /dev/null @@ -1,27 +0,0 @@ -use clap::{builder::PossibleValuesParser, Arg, Command as ClapCommand}; - -use crate::util; - -use super::apply; -pub use super::apply::run; - -pub fn subcommand() -> ClapCommand { - let command = ClapCommand::new("upload-keys") - .about("Upload keys to remote hosts") - .long_about( - r#"Upload keys to remote hosts - -This subcommand behaves as if you invoked `apply` with the pseudo `keys` goal."#, - ) - .arg( - Arg::new("goal") - .hide(true) - .default_value("keys") - .value_parser(PossibleValuesParser::new(["keys"])) - .num_args(1), - ); - - let command = apply::register_deploy_args(command); - - util::register_selector_args(command) -} diff --git a/src/nix/deployment/goal.rs b/src/nix/deployment/goal.rs index c3c51ea..0ff89ac 100644 --- a/src/nix/deployment/goal.rs +++ b/src/nix/deployment/goal.rs @@ -1,7 +1,9 @@ //! Deployment goals. +use std::str::FromStr; + /// The goal of a deployment. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, clap::ValueEnum)] pub enum Goal { /// Build the configurations only. Build, @@ -10,6 +12,7 @@ pub enum Goal { Push, /// Make the configuration the boot default and activate now. + #[default] Switch, /// Make the configuration the boot default. @@ -25,20 +28,37 @@ pub enum Goal { UploadKeys, } -impl Goal { - pub fn from_str(s: &str) -> Option { +impl FromStr for Goal { + type Err = &'static str; + fn from_str(s: &str) -> Result { match s { - "build" => Some(Self::Build), - "push" => Some(Self::Push), - "switch" => Some(Self::Switch), - "boot" => Some(Self::Boot), - "test" => Some(Self::Test), - "dry-activate" => Some(Self::DryActivate), - "keys" => Some(Self::UploadKeys), - _ => None, + "build" => Ok(Self::Build), + "push" => Ok(Self::Push), + "switch" => Ok(Self::Switch), + "boot" => Ok(Self::Boot), + "test" => Ok(Self::Test), + "dry-activate" => Ok(Self::DryActivate), + "keys" => Ok(Self::UploadKeys), + _ => Err("Not one of [build, push, switch, boot, test, dry-activate, keys]."), } } +} +impl std::fmt::Display for Goal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Build => "build", + Self::Push => "push", + Self::Switch => "switch", + Self::Boot => "boot", + Self::Test => "test", + Self::DryActivate => "dry-activate", + Self::UploadKeys => "keys", + }) + } +} + +impl Goal { pub fn as_str(&self) -> Option<&'static str> { use Goal::*; match self { diff --git a/src/nix/deployment/limits.rs b/src/nix/deployment/limits.rs index 5b2d402..27202f6 100644 --- a/src/nix/deployment/limits.rs +++ b/src/nix/deployment/limits.rs @@ -1,5 +1,7 @@ //! Parallelism limits. +use std::str::FromStr; + use tokio::sync::Semaphore; /// Amount of RAM reserved for the system, in MB. @@ -55,9 +57,10 @@ impl ParallelismLimit { /// - A simple heuristic based on remaining memory in the system /// - A supplied number /// - No limit at all -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Default)] pub enum EvaluationNodeLimit { /// Use a naive heuristic based on available memory. + #[default] Heuristic, /// Supply the maximum number of nodes. @@ -67,9 +70,28 @@ pub enum EvaluationNodeLimit { None, } -impl Default for EvaluationNodeLimit { - fn default() -> Self { - Self::Heuristic +impl FromStr for EvaluationNodeLimit { + type Err = &'static str; + fn from_str(s: &str) -> Result { + if s == "auto" { + return Ok(EvaluationNodeLimit::Heuristic); + } + + match s.parse::() { + Ok(0) => Ok(EvaluationNodeLimit::None), + Ok(n) => Ok(EvaluationNodeLimit::Manual(n)), + Err(_) => Err("The value must be a valid number or `auto`"), + } + } +} + +impl std::fmt::Display for EvaluationNodeLimit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Heuristic => write!(f, "auto"), + Self::None => write!(f, "0"), + Self::Manual(n) => write!(f, "{n}"), + } } } diff --git a/src/nix/deployment/options.rs b/src/nix/deployment/options.rs index 768bd22..936de4d 100644 --- a/src/nix/deployment/options.rs +++ b/src/nix/deployment/options.rs @@ -1,6 +1,6 @@ //! Deployment options. -use clap::{builder::PossibleValue, ValueEnum}; +use clap::ValueEnum; use crate::nix::CopyOptions; @@ -36,12 +36,22 @@ pub struct Options { } /// Which evaluator to use. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Default, ValueEnum)] pub enum EvaluatorType { + #[default] Chunked, Streaming, } +impl std::fmt::Display for EvaluatorType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Chunked => "chunked", + Self::Streaming => "streaming", + }) + } +} + impl Options { pub fn set_substituters_push(&mut self, value: bool) { self.substituters_push = value; @@ -98,16 +108,3 @@ impl Default for Options { } } } - -impl ValueEnum for EvaluatorType { - fn value_variants<'a>() -> &'a [Self] { - &[Self::Chunked, Self::Streaming] - } - - fn to_possible_value<'a>(&self) -> Option { - match self { - Self::Chunked => Some(PossibleValue::new("chunked")), - Self::Streaming => Some(PossibleValue::new("streaming")), - } - } -} diff --git a/src/nix/flake.rs b/src/nix/flake.rs index 4e7f72d..5f410ea 100644 --- a/src/nix/flake.rs +++ b/src/nix/flake.rs @@ -53,10 +53,10 @@ impl Flake { } /// Creates a flake from a Flake URI. - pub async fn from_uri(uri: String) -> ColmenaResult { + pub async fn from_uri(uri: impl AsRef) -> ColmenaResult { NixCheck::require_flake_support().await?; - let metadata = FlakeMetadata::resolve(&uri).await?; + let metadata = FlakeMetadata::resolve(uri.as_ref()).await?; Ok(Self { metadata, diff --git a/src/nix/hive/mod.rs b/src/nix/hive/mod.rs index 8831cc8..f9d9227 100644 --- a/src/nix/hive/mod.rs +++ b/src/nix/hive/mod.rs @@ -6,6 +6,7 @@ mod tests; use std::collections::HashMap; use std::convert::AsRef; use std::path::{Path, PathBuf}; +use std::str::FromStr; use tokio::process::Command; use tokio::sync::OnceCell; @@ -16,7 +17,7 @@ use super::{ Flake, MetaConfig, NixExpression, NixFlags, NodeConfig, NodeFilter, NodeName, ProfileDerivation, SerializedNixExpression, StorePath, }; -use crate::error::ColmenaResult; +use crate::error::{ColmenaError, ColmenaResult}; use crate::job::JobHandle; use crate::util::{CommandExecution, CommandExt}; use assets::Assets; @@ -32,6 +33,36 @@ pub enum HivePath { Legacy(PathBuf), } +impl FromStr for HivePath { + type Err = ColmenaError; + + fn from_str(s: &str) -> Result { + // TODO: check for escaped colon maybe? + + let s = s.to_owned(); + let path = std::path::PathBuf::from(&s); + + let fut = async move { + if !path.exists() && s.contains(':') { + // Treat as flake URI + let flake = Flake::from_uri(s).await?; + + log::info!("Using flake: {}", flake.uri()); + + Ok(Self::Flake(flake)) + } else { + HivePath::from_path(path).await + } + }; + + let handle = tokio::runtime::Handle::try_current() + .expect("We should always be executed after we have a runtime"); + std::thread::spawn(move || handle.block_on(fut)) + .join() + .expect("Failed to join future") + } +} + #[derive(Debug)] pub struct Hive { /// Path to the hive. diff --git a/src/nix/node_filter.rs b/src/nix/node_filter.rs index 6f645ca..0e99801 100644 --- a/src/nix/node_filter.rs +++ b/src/nix/node_filter.rs @@ -3,12 +3,33 @@ use std::collections::HashSet; use std::convert::AsRef; use std::iter::{FromIterator, Iterator}; +use std::str::FromStr; +use clap::Args; use glob::Pattern as GlobPattern; use super::{ColmenaError, ColmenaResult, NodeConfig, NodeName}; +#[derive(Debug, Default, Args)] +pub struct NodeFilterOpts { + #[arg( + long, + value_name = "NODES", + help = "Node selector", + long_help = r#"Select a list of nodes to deploy to. + +The list is comma-separated and globs are supported. To match tags, prepend the filter by @. Valid examples: + +- host1,host2,host3 +- edge-* +- edge-*,core-* +- @a-tag,@tags-can-have-*"# + )] + pub on: Option, +} + /// A node filter containing a list of rules. +#[derive(Clone, Debug)] pub struct NodeFilter { rules: Vec, } @@ -16,7 +37,7 @@ pub struct NodeFilter { /// A filter rule. /// /// The filter rules are OR'd together. -#[derive(Debug, Eq, PartialEq)] +#[derive(Debug, Clone, Eq, PartialEq)] enum Rule { /// Matches a node's attribute name. MatchName(GlobPattern), @@ -25,6 +46,13 @@ enum Rule { MatchTag(GlobPattern), } +impl FromStr for NodeFilter { + type Err = ColmenaError; + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + impl NodeFilter { /// Creates a new filter using an expression passed using `--on`. pub fn new>(filter: S) -> ColmenaResult { diff --git a/src/troubleshooter.rs b/src/troubleshooter.rs index f185421..06ec7d6 100644 --- a/src/troubleshooter.rs +++ b/src/troubleshooter.rs @@ -5,28 +5,22 @@ use std::env; use std::future::Future; -use clap::{parser::ValueSource as ClapValueSource, ArgMatches}; use snafu::ErrorCompat; -use crate::error::ColmenaError; +use crate::{error::ColmenaError, nix::HivePath}; /// Runs a closure and tries to troubleshoot if it returns an error. -pub async fn run_wrapped<'a, F, U, T>( - global_args: &'a ArgMatches, - local_args: &'a ArgMatches, - f: U, -) -> T +pub async fn run_wrapped<'a, F, T>(f: F, hive_config: Option) -> T where - U: FnOnce(&'a ArgMatches, &'a ArgMatches) -> F, F: Future>, { - match f(global_args, local_args).await { + match f.await { Ok(r) => r, Err(error) => { log::error!("-----"); log::error!("Operation failed with error: {}", error); - if let Err(own_error) = troubleshoot(global_args, local_args, &error) { + if let Err(own_error) = troubleshoot(hive_config, &error) { log::error!( "Error occurred while trying to troubleshoot another error: {}", own_error @@ -39,17 +33,13 @@ where } } -fn troubleshoot( - global_args: &ArgMatches, - _local_args: &ArgMatches, - error: &ColmenaError, -) -> Result<(), ColmenaError> { +fn troubleshoot(hive_config: Option, error: &ColmenaError) -> Result<(), ColmenaError> { if let ColmenaError::NoFlakesSupport = error { // People following the tutorial might put hive.nix directly // in their Colmena checkout, and encounter NoFlakesSupport // because Colmena always prefers flake.nix when it exists. - if let Some(ClapValueSource::DefaultValue) = global_args.value_source("config") { + if hive_config.is_none() { let cwd = env::current_dir()?; if cwd.join("flake.nix").is_file() && cwd.join("hive.nix").is_file() { eprintln!( @@ -75,8 +65,5 @@ fn troubleshoot( } fn backtrace_enabled() -> bool { - match env::var("RUST_BACKTRACE") { - Ok(backtrace_conf) => backtrace_conf != "0", - _ => false, - } + matches!(env::var("RUST_BACKTRACE"), Ok(backtrace_conf) if backtrace_conf != "0") } diff --git a/src/util.rs b/src/util.rs index e131788..ffbfef5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,9 +1,8 @@ use std::convert::TryFrom; -use std::path::PathBuf; + use std::process::Stdio; use async_trait::async_trait; -use clap::{parser::ValueSource as ClapValueSource, Arg, ArgMatches, Command as ClapCommand}; use futures::future::join3; use serde::de::DeserializeOwned; use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; @@ -12,7 +11,7 @@ use tokio::process::Command; use super::error::{ColmenaError, ColmenaResult}; use super::job::JobHandle; use super::nix::deployment::TargetNodeMap; -use super::nix::{Flake, Hive, HivePath, StorePath}; +use super::nix::StorePath; const NEWLINE: u8 = 0xa; @@ -192,123 +191,6 @@ impl CommandExt for CommandExecution { } } -pub async fn hive_from_args(args: &ArgMatches) -> ColmenaResult { - let path = match args.value_source("config").unwrap() { - ClapValueSource::DefaultValue => { - // traverse upwards until we find hive.nix - let mut cur = std::env::current_dir()?; - let mut file_path = None; - - loop { - let flake = cur.join("flake.nix"); - if flake.is_file() { - file_path = Some(flake); - break; - } - - let legacy = cur.join("hive.nix"); - if legacy.is_file() { - file_path = Some(legacy); - break; - } - - match cur.parent() { - Some(parent) => { - cur = parent.to_owned(); - } - None => { - break; - } - } - } - - if file_path.is_none() { - log::error!( - "Could not find `hive.nix` or `flake.nix` in {:?} or any parent directory", - std::env::current_dir()? - ); - } - - file_path.unwrap() - } - ClapValueSource::CommandLine => { - let path = args - .get_one::("config") - .expect("The config arg should exist") - .to_owned(); - let fpath = PathBuf::from(&path); - - if !fpath.exists() && path.contains(':') { - // Treat as flake URI - let flake = Flake::from_uri(path).await?; - log::info!("Using flake: {}", flake.uri()); - - let hive_path = HivePath::Flake(flake); - - return hive_from_path(hive_path, args).await; - } - - fpath - } - x => panic!("Unexpected value source for config: {:?}", x), - }; - - let hive_path = HivePath::from_path(path).await?; - - hive_from_path(hive_path, args).await -} - -pub async fn hive_from_path(hive_path: HivePath, args: &ArgMatches) -> ColmenaResult { - match &hive_path { - HivePath::Legacy(p) => { - log::info!("Using configuration: {}", p.to_string_lossy()); - } - HivePath::Flake(flake) => { - log::info!("Using flake: {}", flake.uri()); - } - } - - let mut hive = Hive::new(hive_path).await?; - - if args.get_flag("show-trace") { - hive.set_show_trace(true); - } - - if args.get_flag("impure") { - hive.set_impure(true); - } - - if let Some(opts) = args.get_many::("nix-option") { - let iter = opts; - - let names = iter.clone().step_by(2); - let values = iter.clone().skip(1).step_by(2); - - for (name, value) in names.zip(values) { - hive.add_nix_option(name.to_owned(), value.to_owned()); - } - } - - Ok(hive) -} - -pub fn register_selector_args(command: ClapCommand) -> ClapCommand { - command - .arg(Arg::new("on") - .long("on") - .value_name("NODES") - .help("Node selector") - .long_help(r#"Select a list of nodes to deploy to. - -The list is comma-separated and globs are supported. To match tags, prepend the filter by @. Valid examples: - -- host1,host2,host3 -- edge-* -- edge-*,core-* -- @a-tag,@tags-can-have-*"#) - .num_args(1)) -} - pub async fn capture_stream( mut stream: BufReader, job: Option,