diff options
author | Alexander Bantyev | 2021-08-04 12:10:08 +0300 |
---|---|---|
committer | GitHub | 2021-08-04 12:10:08 +0300 |
commit | d72174307d5b88ec24cc2e69e875228fe3d642ed (patch) | |
tree | 805a20aa25b93cc8b5d81387287d6d2eb4e2d5de | |
parent | a0dd40684d292203b751f6e49c369503f83d1c2a (diff) | |
parent | 2d06590cb0ea6a6e0f54866661cc5e665433bc71 (diff) |
Merge pull request #104 from nrdxp/as-lib
Use as library
Diffstat (limited to '')
-rw-r--r-- | Cargo.lock | 24 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | src/bin/deploy.rs | 670 | ||||
-rw-r--r-- | src/cli.rs | 676 | ||||
-rw-r--r-- | src/lib.rs | 1 |
5 files changed, 692 insertions, 681 deletions
@@ -40,9 +40,9 @@ checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" [[package]] name = "bytes" -version = "0.6.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0dcbc35f504eb6fc275a6d20e4ebcda18cf50d40ba6fabff8c711fa16cb3b16" +checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" [[package]] name = "cbitset" @@ -675,9 +675,9 @@ checksum = "eba180dafb9038b050a4c280019bbedf9f2467b61e5d892dcad585bb57aadc5a" [[package]] name = "proc-macro2" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" dependencies = [ "unicode-xid", ] @@ -852,9 +852,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "1.0.54" +version = "1.0.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2af957a63d6bd42255c359c93d9bfdb97076bd3b820897ce55ffbfbf107f44" +checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7" dependencies = [ "proc-macro2", "quote", @@ -933,31 +933,29 @@ dependencies = [ [[package]] name = "tokio" -version = "0.3.5" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12a3eb39ee2c231be64487f1fcbe726c8f2514876a55480a5ab8559fc374252" +checksum = "4b7b349f11a7047e6d1276853e612d152f5e8a352c61917887cc2169e2366b4c" dependencies = [ "autocfg", "bytes", - "futures-core", - "lazy_static", "libc", "memchr", "mio 0.7.6", "num_cpus", + "once_cell", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "tokio-macros", "winapi 0.3.9", ] [[package]] name = "tokio-macros" -version = "0.3.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d30fdbb5dc2d8f91049691aa1a9d4d4ae422a21c334ce8936e5886d30c5c45" +checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110" dependencies = [ "proc-macro2", "quote", @@ -23,7 +23,7 @@ serde = { version = "1.0.104", features = [ "derive" ] } serde_json = "1.0.48" signal-hook = "0.3" thiserror = "1.0" -tokio = { version = "0.3.5", features = [ "full" ] } +tokio = { version = "1.9.0", features = [ "full" ] } toml = "0.5" whoami = "0.9.0" yn = "0.1" diff --git a/src/bin/deploy.rs b/src/bin/deploy.rs index 7f6fd20..e924dc9 100644 --- a/src/bin/deploy.rs +++ b/src/bin/deploy.rs @@ -3,676 +3,12 @@ // // SPDX-License-Identifier: MPL-2.0 -use std::collections::HashMap; -use std::io::{stdin, stdout, Write}; - -use clap::Clap; - -use deploy::{DeployFlake, ParseFlakeError}; -use futures_util::stream::{StreamExt, TryStreamExt}; -use log::{debug, error, info, warn}; -use serde::Serialize; -use std::process::Stdio; -use thiserror::Error; -use tokio::process::Command; - -/// Simple Rust rewrite of a simple Nix Flake deployment tool -#[derive(Clap, Debug, Clone)] -#[clap(version = "1.0", author = "Serokell <https://serokell.io/>")] -struct Opts { - /// The flake to deploy - #[clap(group = "deploy")] - target: Option<String>, - - /// A list of flakes to deploy alternatively - #[clap(long, group = "deploy")] - targets: Option<Vec<String>>, - /// Check signatures when using `nix copy` - #[clap(short, long)] - checksigs: bool, - /// Use the interactive prompt before deployment - #[clap(short, long)] - interactive: bool, - /// Extra arguments to be passed to nix build - extra_build_args: Vec<String>, - - /// Print debug logs to output - #[clap(short, long)] - debug_logs: bool, - /// Directory to print logs to (including the background activation process) - #[clap(long)] - log_dir: Option<String>, - - /// Keep the build outputs of each built profile - #[clap(short, long)] - keep_result: bool, - /// Location to keep outputs from built profiles in - #[clap(short, long)] - result_path: Option<String>, - - /// Skip the automatic pre-build checks - #[clap(short, long)] - skip_checks: bool, - - /// Override the SSH user with the given value - #[clap(long)] - ssh_user: Option<String>, - /// Override the profile user with the given value - #[clap(long)] - profile_user: Option<String>, - /// Override the SSH options used - #[clap(long)] - ssh_opts: Option<String>, - /// Override if the connecting to the target node should be considered fast - #[clap(long)] - fast_connection: Option<bool>, - /// Override if a rollback should be attempted if activation fails - #[clap(long)] - auto_rollback: Option<bool>, - /// Override hostname used for the node - #[clap(long)] - hostname: Option<String>, - /// Make activation wait for confirmation, or roll back after a period of time - #[clap(long)] - magic_rollback: Option<bool>, - /// How long activation should wait for confirmation (if using magic-rollback) - #[clap(long)] - confirm_timeout: Option<u16>, - /// Where to store temporary files (only used by magic-rollback) - #[clap(long)] - temp_path: Option<String>, - /// Show what will be activated on the machines - #[clap(long)] - dry_activate: bool, - /// Revoke all previously succeeded deploys when deploying multiple profiles - #[clap(long)] - rollback_succeeded: Option<bool>, -} - -/// Returns if the available Nix installation supports flakes -async fn test_flake_support() -> Result<bool, std::io::Error> { - debug!("Checking for flake support"); - - Ok(Command::new("nix") - .arg("eval") - .arg("--expr") - .arg("builtins.getFlake") - // This will error on some machines "intentionally", and we don't really need that printing - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await? - .success()) -} - -#[derive(Error, Debug)] -enum CheckDeploymentError { - #[error("Failed to execute Nix checking command: {0}")] - NixCheck(#[from] std::io::Error), - #[error("Nix checking command resulted in a bad exit code: {0:?}")] - NixCheckExit(Option<i32>), -} - -async fn check_deployment( - supports_flakes: bool, - repo: &str, - extra_build_args: &[String], -) -> Result<(), CheckDeploymentError> { - info!("Running checks for flake in {}", repo); - - let mut check_command = match supports_flakes { - true => Command::new("nix"), - false => Command::new("nix-build"), - }; - - match supports_flakes { - true => { - check_command.arg("flake").arg("check").arg(repo); - } - false => { - check_command.arg("-E") - .arg("--no-out-link") - .arg(format!("let r = import {}/.; x = (if builtins.isFunction r then (r {{}}) else r); in if x ? checks then x.checks.${{builtins.currentSystem}} else {{}}", repo)); - } - }; - - for extra_arg in extra_build_args { - check_command.arg(extra_arg); - } - - let check_status = check_command.status().await?; - - match check_status.code() { - Some(0) => (), - a => return Err(CheckDeploymentError::NixCheckExit(a)), - }; - - Ok(()) -} - -#[derive(Error, Debug)] -enum GetDeploymentDataError { - #[error("Failed to execute nix eval command: {0}")] - NixEval(std::io::Error), - #[error("Failed to read output from evaluation: {0}")] - NixEvalOut(std::io::Error), - #[error("Evaluation resulted in a bad exit code: {0:?}")] - NixEvalExit(Option<i32>), - #[error("Error converting evaluation output to utf8: {0}")] - DecodeUtf8(#[from] std::string::FromUtf8Error), - #[error("Error decoding the JSON from evaluation: {0}")] - DecodeJson(#[from] serde_json::error::Error), - #[error("Impossible happened: profile is set but node is not")] - ProfileNoNode, -} - -/// Evaluates the Nix in the given `repo` and return the processed Data from it -async fn get_deployment_data( - supports_flakes: bool, - flakes: &[deploy::DeployFlake<'_>], - extra_build_args: &[String], -) -> Result<Vec<deploy::data::Data>, GetDeploymentDataError> { - futures_util::stream::iter(flakes).then(|flake| async move { - - info!("Evaluating flake in {}", flake.repo); - - let mut c = if supports_flakes { - Command::new("nix") - } else { - Command::new("nix-instantiate") - }; - - if supports_flakes { - c.arg("eval") - .arg("--json") - .arg(format!("{}#deploy", flake.repo)) - // We use --apply instead of --expr so that we don't have to deal with builtins.getFlake - .arg("--apply"); - match (&flake.node, &flake.profile) { - (Some(node), Some(profile)) => { - // Ignore all nodes and all profiles but the one we're evaluating - c.arg(format!( - r#" - deploy: - (deploy // {{ - nodes = {{ - "{0}" = deploy.nodes."{0}" // {{ - profiles = {{ - inherit (deploy.nodes."{0}".profiles) "{1}"; - }}; - }}; - }}; - }}) - "#, - node, profile - )) - } - (Some(node), None) => { - // Ignore all nodes but the one we're evaluating - c.arg(format!( - r#" - deploy: - (deploy // {{ - nodes = {{ - inherit (deploy.nodes) "{}"; - }}; - }}) - "#, - node - )) - } - (None, None) => { - // We need to evaluate all profiles of all nodes anyway, so just do it strictly - c.arg("deploy: deploy") - } - (None, Some(_)) => return Err(GetDeploymentDataError::ProfileNoNode), - } - } else { - c - .arg("--strict") - .arg("--read-write-mode") - .arg("--json") - .arg("--eval") - .arg("-E") - .arg(format!("let r = import {}/.; in if builtins.isFunction r then (r {{}}).deploy else r.deploy", flake.repo)) - }; - - for extra_arg in extra_build_args { - c.arg(extra_arg); - } - - let build_child = c - .stdout(Stdio::piped()) - .spawn() - .map_err(GetDeploymentDataError::NixEval)?; - - let build_output = build_child - .wait_with_output() - .await - .map_err(GetDeploymentDataError::NixEvalOut)?; - - match build_output.status.code() { - Some(0) => (), - a => return Err(GetDeploymentDataError::NixEvalExit(a)), - }; - - let data_json = String::from_utf8(build_output.stdout)?; - - Ok(serde_json::from_str(&data_json)?) -}).try_collect().await -} - -#[derive(Serialize)] -struct PromptPart<'a> { - user: &'a str, - ssh_user: &'a str, - path: &'a str, - hostname: &'a str, - ssh_opts: &'a [String], -} - -fn print_deployment( - parts: &[( - &deploy::DeployFlake<'_>, - deploy::DeployData, - deploy::DeployDefs, - )], -) -> Result<(), toml::ser::Error> { - let mut part_map: HashMap<String, HashMap<String, PromptPart>> = HashMap::new(); - - for (_, data, defs) in parts { - part_map - .entry(data.node_name.to_string()) - .or_insert_with(HashMap::new) - .insert( - data.profile_name.to_string(), - PromptPart { - user: &defs.profile_user, - ssh_user: &defs.ssh_user, - path: &data.profile.profile_settings.path, - hostname: &data.node.node_settings.hostname, - ssh_opts: &data.merged_settings.ssh_opts, - }, - ); - } - - let toml = toml::to_string(&part_map)?; - - info!("The following profiles are going to be deployed:\n{}", toml); - - Ok(()) -} -#[derive(Error, Debug)] -enum PromptDeploymentError { - #[error("Failed to make printable TOML of deployment: {0}")] - TomlFormat(#[from] toml::ser::Error), - #[error("Failed to flush stdout prior to query: {0}")] - StdoutFlush(std::io::Error), - #[error("Failed to read line from stdin: {0}")] - StdinRead(std::io::Error), - #[error("User cancelled deployment")] - Cancelled, -} - -fn prompt_deployment( - parts: &[( - &deploy::DeployFlake<'_>, - deploy::DeployData, - deploy::DeployDefs, - )], -) -> Result<(), PromptDeploymentError> { - print_deployment(parts)?; - - info!("Are you sure you want to deploy these profiles?"); - print!("> "); - - stdout() - .flush() - .map_err(PromptDeploymentError::StdoutFlush)?; - - let mut s = String::new(); - stdin() - .read_line(&mut s) - .map_err(PromptDeploymentError::StdinRead)?; - - if !yn::yes(&s) { - if yn::is_somewhat_yes(&s) { - info!("Sounds like you might want to continue, to be more clear please just say \"yes\". Do you want to deploy these profiles?"); - print!("> "); - - stdout() - .flush() - .map_err(PromptDeploymentError::StdoutFlush)?; - - let mut s = String::new(); - stdin() - .read_line(&mut s) - .map_err(PromptDeploymentError::StdinRead)?; - - if !yn::yes(&s) { - return Err(PromptDeploymentError::Cancelled); - } - } else { - if !yn::no(&s) { - info!( - "That was unclear, but sounded like a no to me. Please say \"yes\" or \"no\" to be more clear." - ); - } - - return Err(PromptDeploymentError::Cancelled); - } - } - - Ok(()) -} - -#[derive(Error, Debug)] -enum RunDeployError { - #[error("Failed to deploy profile: {0}")] - DeployProfile(#[from] deploy::deploy::DeployProfileError), - #[error("Failed to push profile: {0}")] - PushProfile(#[from] deploy::push::PushProfileError), - #[error("No profile named `{0}` was found")] - ProfileNotFound(String), - #[error("No node named `{0}` was found")] - NodeNotFound(String), - #[error("Profile was provided without a node name")] - ProfileWithoutNode, - #[error("Error processing deployment definitions: {0}")] - DeployDataDefs(#[from] deploy::DeployDataDefsError), - #[error("Failed to make printable TOML of deployment: {0}")] - TomlFormat(#[from] toml::ser::Error), - #[error("{0}")] - PromptDeployment(#[from] PromptDeploymentError), - #[error("Failed to revoke profile: {0}")] - RevokeProfile(#[from] deploy::deploy::RevokeProfileError), -} - -type ToDeploy<'a> = Vec<( - &'a deploy::DeployFlake<'a>, - &'a deploy::data::Data, - (&'a str, &'a deploy::data::Node), - (&'a str, &'a deploy::data::Profile), -)>; - -async fn run_deploy( - deploy_flakes: Vec<deploy::DeployFlake<'_>>, - data: Vec<deploy::data::Data>, - supports_flakes: bool, - check_sigs: bool, - interactive: bool, - cmd_overrides: &deploy::CmdOverrides, - keep_result: bool, - result_path: Option<&str>, - extra_build_args: &[String], - debug_logs: bool, - dry_activate: bool, - log_dir: &Option<String>, - rollback_succeeded: bool, -) -> Result<(), RunDeployError> { - let to_deploy: ToDeploy = deploy_flakes - .iter() - .zip(&data) - .map(|(deploy_flake, data)| { - let to_deploys: ToDeploy = match (&deploy_flake.node, &deploy_flake.profile) { - (Some(node_name), Some(profile_name)) => { - let node = match data.nodes.get(node_name) { - Some(x) => x, - None => Err(RunDeployError::NodeNotFound(node_name.to_owned()))?, - }; - let profile = match node.node_settings.profiles.get(profile_name) { - Some(x) => x, - None => Err(RunDeployError::ProfileNotFound(profile_name.to_owned()))?, - }; - - vec![( - &deploy_flake, - &data, - (node_name.as_str(), node), - (profile_name.as_str(), profile), - )] - } - (Some(node_name), None) => { - let node = match data.nodes.get(node_name) { - Some(x) => x, - None => return Err(RunDeployError::NodeNotFound(node_name.to_owned())), - }; - - let mut profiles_list: Vec<(&str, &deploy::data::Profile)> = Vec::new(); - - for profile_name in [ - node.node_settings.profiles_order.iter().collect(), - node.node_settings.profiles.keys().collect::<Vec<&String>>(), - ] - .concat() - { - let profile = match node.node_settings.profiles.get(profile_name) { - Some(x) => x, - None => { - return Err(RunDeployError::ProfileNotFound( - profile_name.to_owned(), - )) - } - }; - - if !profiles_list.iter().any(|(n, _)| n == profile_name) { - profiles_list.push((&profile_name, profile)); - } - } - - profiles_list - .into_iter() - .map(|x| (deploy_flake, data, (node_name.as_str(), node), x)) - .collect() - } - (None, None) => { - let mut l = Vec::new(); - - for (node_name, node) in &data.nodes { - let mut profiles_list: Vec<(&str, &deploy::data::Profile)> = Vec::new(); - - for profile_name in [ - node.node_settings.profiles_order.iter().collect(), - node.node_settings.profiles.keys().collect::<Vec<&String>>(), - ] - .concat() - { - let profile = match node.node_settings.profiles.get(profile_name) { - Some(x) => x, - None => { - return Err(RunDeployError::ProfileNotFound( - profile_name.to_owned(), - )) - } - }; - - if !profiles_list.iter().any(|(n, _)| n == profile_name) { - profiles_list.push((&profile_name, profile)); - } - } - - let ll: ToDeploy = profiles_list - .into_iter() - .map(|x| (deploy_flake, data, (node_name.as_str(), node), x)) - .collect(); - - l.extend(ll); - } - - l - } - (None, Some(_)) => return Err(RunDeployError::ProfileWithoutNode), - }; - Ok(to_deploys) - }) - .collect::<Result<Vec<ToDeploy>, RunDeployError>>()? - .into_iter() - .flatten() - .collect(); - - let mut parts: Vec<( - &deploy::DeployFlake<'_>, - deploy::DeployData, - deploy::DeployDefs, - )> = Vec::new(); - - for (deploy_flake, data, (node_name, node), (profile_name, profile)) in to_deploy { - let deploy_data = deploy::make_deploy_data( - &data.generic_settings, - node, - node_name, - profile, - profile_name, - &cmd_overrides, - debug_logs, - log_dir.as_deref(), - ); - - let deploy_defs = deploy_data.defs()?; - - parts.push((deploy_flake, deploy_data, deploy_defs)); - } - - if interactive { - prompt_deployment(&parts[..])?; - } else { - print_deployment(&parts[..])?; - } - - for (deploy_flake, deploy_data, deploy_defs) in &parts { - deploy::push::push_profile(deploy::push::PushProfileData { - supports_flakes, - check_sigs, - repo: deploy_flake.repo, - deploy_data: &deploy_data, - deploy_defs: &deploy_defs, - keep_result, - result_path, - extra_build_args, - }) - .await?; - } - - let mut succeeded: Vec<(&deploy::DeployData, &deploy::DeployDefs)> = vec![]; - - // Run all deployments - // In case of an error rollback any previoulsy made deployment. - // Rollbacks adhere to the global seeting to auto_rollback and secondary - // the profile's configuration - for (_, deploy_data, deploy_defs) in &parts { - if let Err(e) = deploy::deploy::deploy_profile(deploy_data, deploy_defs, dry_activate).await - { - error!("{}", e); - if dry_activate { - info!("dry run, not rolling back"); - } - info!("Revoking previous deploys"); - if rollback_succeeded && cmd_overrides.auto_rollback.unwrap_or(true) { - // revoking all previous deploys - // (adheres to profile configuration if not set explicitely by - // the command line) - for (deploy_data, deploy_defs) in &succeeded { - if deploy_data.merged_settings.auto_rollback.unwrap_or(true) { - deploy::deploy::revoke(*deploy_data, *deploy_defs).await?; - } - } - } - break; - } - succeeded.push((deploy_data, deploy_defs)) - } - - Ok(()) -} - -#[derive(Error, Debug)] -enum RunError { - #[error("Failed to deploy profile: {0}")] - DeployProfile(#[from] deploy::deploy::DeployProfileError), - #[error("Failed to push profile: {0}")] - PushProfile(#[from] deploy::push::PushProfileError), - #[error("Failed to test for flake support: {0}")] - FlakeTest(std::io::Error), - #[error("Failed to check deployment: {0}")] - CheckDeployment(#[from] CheckDeploymentError), - #[error("Failed to evaluate deployment data: {0}")] - GetDeploymentData(#[from] GetDeploymentDataError), - #[error("Error parsing flake: {0}")] - ParseFlake(#[from] deploy::ParseFlakeError), - #[error("Error initiating logger: {0}")] - Logger(#[from] flexi_logger::FlexiLoggerError), - #[error("{0}")] - RunDeploy(#[from] RunDeployError), -} - -async fn run() -> Result<(), RunError> { - let opts: Opts = Opts::parse(); - - deploy::init_logger( - opts.debug_logs, - opts.log_dir.as_deref(), - deploy::LoggerType::Deploy, - )?; - - let deploys = opts - .clone() - .targets - .unwrap_or_else(|| vec![opts.clone().target.unwrap_or(".".to_string())]); - - let deploy_flakes: Vec<DeployFlake> = deploys - .iter() - .map(|f| deploy::parse_flake(f.as_str())) - .collect::<Result<Vec<DeployFlake>, ParseFlakeError>>()?; - - let cmd_overrides = deploy::CmdOverrides { - ssh_user: opts.ssh_user, - profile_user: opts.profile_user, - ssh_opts: opts.ssh_opts, - fast_connection: opts.fast_connection, - auto_rollback: opts.auto_rollback, - hostname: opts.hostname, - magic_rollback: opts.magic_rollback, - temp_path: opts.temp_path, - confirm_timeout: opts.confirm_timeout, - dry_activate: opts.dry_activate, - }; - - let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?; - - if !supports_flakes { - warn!("A Nix version without flakes support was detected, support for this is work in progress"); - } - - if !opts.skip_checks { - for deploy_flake in deploy_flakes.iter() { - check_deployment(supports_flakes, deploy_flake.repo, &opts.extra_build_args).await?; - } - } - let result_path = opts.result_path.as_deref(); - let data = get_deployment_data(supports_flakes, &deploy_flakes, &opts.extra_build_args).await?; - run_deploy( - deploy_flakes, - data, - supports_flakes, - opts.checksigs, - opts.interactive, - &cmd_overrides, - opts.keep_result, - result_path, - &opts.extra_build_args, - opts.debug_logs, - opts.dry_activate, - &opts.log_dir, - opts.rollback_succeeded.unwrap_or(true), - ) - .await?; - - Ok(()) -} +use deploy::cli; +use log::error; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { - match run().await { + match cli::run(None).await { Ok(()) => (), Err(err) => { error!("{}", err); diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..33eb5bb --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,676 @@ +// SPDX-FileCopyrightText: 2020 Serokell <https://serokell.io/> +// SPDX-FileCopyrightText: 2021 Yannik Sander <contact@ysndr.de> +// +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; +use std::io::{stdin, stdout, Write}; + +use clap::{Clap, ArgMatches, FromArgMatches}; + +use crate as deploy; + +use self::deploy::{DeployFlake, ParseFlakeError}; +use futures_util::stream::{StreamExt, TryStreamExt}; +use log::{debug, error, info, warn}; +use serde::Serialize; +use std::process::Stdio; +use thiserror::Error; +use tokio::process::Command; + +/// Simple Rust rewrite of a simple Nix Flake deployment tool +#[derive(Clap, Debug, Clone)] +#[clap(version = "1.0", author = "Serokell <https://serokell.io/>")] +pub struct Opts { + /// The flake to deploy + #[clap(group = "deploy")] + target: Option<String>, + + /// A list of flakes to deploy alternatively + #[clap(long, group = "deploy")] + targets: Option<Vec<String>>, + /// Check signatures when using `nix copy` + #[clap(short, long)] + checksigs: bool, + /// Use the interactive prompt before deployment + #[clap(short, long)] + interactive: bool, + /// Extra arguments to be passed to nix build + extra_build_args: Vec<String>, + + /// Print debug logs to output + #[clap(short, long)] + debug_logs: bool, + /// Directory to print logs to (including the background activation process) + #[clap(long)] + log_dir: Option<String>, + + /// Keep the build outputs of each built profile + #[clap(short, long)] + keep_result: bool, + /// Location to keep outputs from built profiles in + #[clap(short, long)] + result_path: Option<String>, + + /// Skip the automatic pre-build checks + #[clap(short, long)] + skip_checks: bool, + + /// Override the SSH user with the given value + #[clap(long)] + ssh_user: Option<String>, + /// Override the profile user with the given value + #[clap(long)] + profile_user: Option<String>, + /// Override the SSH options used + #[clap(long)] + ssh_opts: Option<String>, + /// Override if the connecting to the target node should be considered fast + #[clap(long)] + fast_connection: Option<bool>, + /// Override if a rollback should be attempted if activation fails + #[clap(long)] + auto_rollback: Option<bool>, + /// Override hostname used for the node + #[clap(long)] + hostname: Option<String>, + /// Make activation wait for confirmation, or roll back after a period of time + #[clap(long)] + magic_rollback: Option<bool>, + /// How long activation should wait for confirmation (if using magic-rollback) + #[clap(long)] + confirm_timeout: Option<u16>, + /// Where to store temporary files (only used by magic-rollback) + #[clap(long)] + temp_path: Option<String>, + /// Show what will be activated on the machines + #[clap(long)] + dry_activate: bool, + /// Revoke all previously succeeded deploys when deploying multiple profiles + #[clap(long)] + rollback_succeeded: Option<bool>, +} + +/// Returns if the available Nix installation supports flakes +async fn test_flake_support() -> Result<bool, std::io::Error> { + debug!("Checking for flake support"); + + Ok(Command::new("nix") + .arg("eval") + .arg("--expr") + .arg("builtins.getFlake") + // This will error on some machines "intentionally", and we don't really need that printing + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await? + .success()) +} + +#[derive(Error, Debug)] +pub enum CheckDeploymentError { + #[error("Failed to execute Nix checking command: {0}")] + NixCheck(#[from] std::io::Error), + #[error("Nix checking command resulted in a bad exit code: {0:?}")] + NixCheckExit(Option<i32>), +} + +async fn check_deployment( + supports_flakes: bool, + repo: &str, + extra_build_args: &[String], +) -> Result<(), CheckDeploymentError> { + info!("Running checks for flake in {}", repo); + + let mut check_command = match supports_flakes { + true => Command::new("nix"), + false => Command::new("nix-build"), + }; + + match supports_flakes { + true => { + check_command.arg("flake").arg("check").arg(repo); + } + false => { + check_command.arg("-E") + .arg("--no-out-link") + .arg(format!("let r = import {}/.; x = (if builtins.isFunction r then (r {{}}) else r); in if x ? checks then x.checks.${{builtins.currentSystem}} else {{}}", repo)); + } + }; + + for extra_arg in extra_build_args { + check_command.arg(extra_arg); + } + + let check_status = check_command.status().await?; + + match check_status.code() { + Some(0) => (), + a => return Err(CheckDeploymentError::NixCheckExit(a)), + }; + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum GetDeploymentDataError { + #[error("Failed to execute nix eval command: {0}")] + NixEval(std::io::Error), + #[error("Failed to read output from evaluation: {0}")] + NixEvalOut(std::io::Error), + #[error("Evaluation resulted in a bad exit code: {0:?}")] + NixEvalExit(Option<i32>), + #[error("Error converting evaluation output to utf8: {0}")] + DecodeUtf8(#[from] std::string::FromUtf8Error), + #[error("Error decoding the JSON from evaluation: {0}")] + DecodeJson(#[from] serde_json::error::Error), + #[error("Impossible happened: profile is set but node is not")] + ProfileNoNode, +} + +/// Evaluates the Nix in the given `repo` and return the processed Data from it +async fn get_deployment_data( + supports_flakes: bool, + flakes: &[deploy::DeployFlake<'_>], + extra_build_args: &[String], +) -> Result<Vec<deploy::data::Data>, GetDeploymentDataError> { + futures_util::stream::iter(flakes).then(|flake| async move { + + info!("Evaluating flake in {}", flake.repo); + + let mut c = if supports_flakes { + Command::new("nix") + } else { + Command::new("nix-instantiate") + }; + + if supports_flakes { + c.arg("eval") + .arg("--json") + .arg(format!("{}#deploy", flake.repo)) + // We use --apply instead of --expr so that we don't have to deal with builtins.getFlake + .arg("--apply"); + match (&flake.node, &flake.profile) { + (Some(node), Some(profile)) => { + // Ignore all nodes and all profiles but the one we're evaluating + c.arg(format!( + r#" + deploy: + (deploy // {{ + nodes = {{ + "{0}" = deploy.nodes."{0}" // {{ + profiles = {{ + inherit (deploy.nodes."{0}".profiles) "{1}"; + }}; + }}; + }}; + }}) + "#, + node, profile + )) + } + (Some(node), None) => { + // Ignore all nodes but the one we're evaluating + c.arg(format!( + r#" + deploy: + (deploy // {{ + nodes = {{ + inherit (deploy.nodes) "{}"; + }}; + }}) + "#, + node + )) + } + (None, None) => { + // We need to evaluate all profiles of all nodes anyway, so just do it strictly + c.arg("deploy: deploy") + } + (None, Some(_)) => return Err(GetDeploymentDataError::ProfileNoNode), + } + } else { + c + .arg("--strict") + .arg("--read-write-mode") + .arg("--json") + .arg("--eval") + .arg("-E") + .arg(format!("let r = import {}/.; in if builtins.isFunction r then (r {{}}).deploy else r.deploy", flake.repo)) + }; + + for extra_arg in extra_build_args { + c.arg(extra_arg); + } + + let build_child = c + .stdout(Stdio::piped()) + .spawn() + .map_err(GetDeploymentDataError::NixEval)?; + + let build_output = build_child + .wait_with_output() + .await + .map_err(GetDeploymentDataError::NixEvalOut)?; + + match build_output.status.code() { + Some(0) => (), + a => return Err(GetDeploymentDataError::NixEvalExit(a)), + }; + + let data_json = String::from_utf8(build_output.stdout)?; + + Ok(serde_json::from_str(&data_json)?) +}).try_collect().await +} + +#[derive(Serialize)] +struct PromptPart<'a> { + user: &'a str, + ssh_user: &'a str, + path: &'a str, + hostname: &'a str, + ssh_opts: &'a [String], +} + +fn print_deployment( + parts: &[( + &deploy::DeployFlake<'_>, + deploy::DeployData, + deploy::DeployDefs, + )], +) -> Result<(), toml::ser::Error> { + let mut part_map: HashMap<String, HashMap<String, PromptPart>> = HashMap::new(); + + for (_, data, defs) in parts { + part_map + .entry(data.node_name.to_string()) + .or_insert_with(HashMap::new) + .insert( + data.profile_name.to_string(), + PromptPart { + user: &defs.profile_user, + ssh_user: &defs.ssh_user, + path: &data.profile.profile_settings.path, + hostname: &data.node.node_settings.hostname, + ssh_opts: &data.merged_settings.ssh_opts, + }, + ); + } + + let toml = toml::to_string(&part_map)?; + + info!("The following profiles are going to be deployed:\n{}", toml); + + Ok(()) +} +#[derive(Error, Debug)] +pub enum PromptDeploymentError { + #[error("Failed to make printable TOML of deployment: {0}")] + TomlFormat(#[from] toml::ser::Error), + #[error("Failed to flush stdout prior to query: {0}")] + StdoutFlush(std::io::Error), + #[error("Failed to read line from stdin: {0}")] + StdinRead(std::io::Error), + #[error("User cancelled deployment")] + Cancelled, +} + +fn prompt_deployment( + parts: &[( + &deploy::DeployFlake<'_>, + deploy::DeployData, + deploy::DeployDefs, + )], +) -> Result<(), PromptDeploymentError> { + print_deployment(parts)?; + + info!("Are you sure you want to deploy these profiles?"); + print!("> "); + + stdout() + .flush() + .map_err(PromptDeploymentError::StdoutFlush)?; + + let mut s = String::new(); + stdin() + .read_line(&mut s) + .map_err(PromptDeploymentError::StdinRead)?; + + if !yn::yes(&s) { + if yn::is_somewhat_yes(&s) { + info!("Sounds like you might want to continue, to be more clear please just say \"yes\". Do you want to deploy these profiles?"); + print!("> "); + + stdout() + .flush() + .map_err(PromptDeploymentError::StdoutFlush)?; + + let mut s = String::new(); + stdin() + .read_line(&mut s) + .map_err(PromptDeploymentError::StdinRead)?; + + if !yn::yes(&s) { + return Err(PromptDeploymentError::Cancelled); + } + } else { + if !yn::no(&s) { + info!( + "That was unclear, but sounded like a no to me. Please say \"yes\" or \"no\" to be more clear." + ); + } + + return Err(PromptDeploymentError::Cancelled); + } + } + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum RunDeployError { + #[error("Failed to deploy profile: {0}")] + DeployProfile(#[from] deploy::deploy::DeployProfileError), + #[error("Failed to push profile: {0}")] + PushProfile(#[from] deploy::push::PushProfileError), + #[error("No profile named `{0}` was found")] + ProfileNotFound(String), + #[error("No node named `{0}` was found")] + NodeNotFound(String), + #[error("Profile was provided without a node name")] + ProfileWithoutNode, + #[error("Error processing deployment definitions: {0}")] + DeployDataDefs(#[from] deploy::DeployDataDefsError), + #[error("Failed to make printable TOML of deployment: {0}")] + TomlFormat(#[from] toml::ser::Error), + #[error("{0}")] + PromptDeployment(#[from] PromptDeploymentError), + #[error("Failed to revoke profile: {0}")] + RevokeProfile(#[from] deploy::deploy::RevokeProfileError), +} + +type ToDeploy<'a> = Vec<( + &'a deploy::DeployFlake<'a>, + &'a deploy::data::Data, + (&'a str, &'a deploy::data::Node), + (&'a str, &'a deploy::data::Profile), +)>; + +async fn run_deploy( + deploy_flakes: Vec<deploy::DeployFlake<'_>>, + data: Vec<deploy::data::Data>, + supports_flakes: bool, + check_sigs: bool, + interactive: bool, + cmd_overrides: &deploy::CmdOverrides, + keep_result: bool, + result_path: Option<&str>, + extra_build_args: &[String], + debug_logs: bool, + dry_activate: bool, + log_dir: &Option<String>, + rollback_succeeded: bool, +) -> Result<(), RunDeployError> { + let to_deploy: ToDeploy = deploy_flakes + .iter() + .zip(&data) + .map(|(deploy_flake, data)| { + let to_deploys: ToDeploy = match (&deploy_flake.node, &deploy_flake.profile) { + (Some(node_name), Some(profile_name)) => { + let node = match data.nodes.get(node_name) { + Some(x) => x, + None => Err(RunDeployError::NodeNotFound(node_name.to_owned()))?, + }; + let profile = match node.node_settings.profiles.get(profile_name) { + Some(x) => x, + None => Err(RunDeployError::ProfileNotFound(profile_name.to_owned()))?, + }; + + vec![( + &deploy_flake, + &data, + (node_name.as_str(), node), + (profile_name.as_str(), profile), + )] + } + (Some(node_name), None) => { + let node = match data.nodes.get(node_name) { + Some(x) => x, + None => return Err(RunDeployError::NodeNotFound(node_name.to_owned())), + }; + + let mut profiles_list: Vec<(&str, &deploy::data::Profile)> = Vec::new(); + + for profile_name in [ + node.node_settings.profiles_order.iter().collect(), + node.node_settings.profiles.keys().collect::<Vec<&String>>(), + ] + .concat() + { + let profile = match node.node_settings.profiles.get(profile_name) { + Some(x) => x, + None => { + return Err(RunDeployError::ProfileNotFound( + profile_name.to_owned(), + )) + } + }; + + if !profiles_list.iter().any(|(n, _)| n == profile_name) { + profiles_list.push((&profile_name, profile)); + } + } + + profiles_list + .into_iter() + .map(|x| (deploy_flake, data, (node_name.as_str(), node), x)) + .collect() + } + (None, None) => { + let mut l = Vec::new(); + + for (node_name, node) in &data.nodes { + let mut profiles_list: Vec<(&str, &deploy::data::Profile)> = Vec::new(); + + for profile_name in [ + node.node_settings.profiles_order.iter().collect(), + node.node_settings.profiles.keys().collect::<Vec<&String>>(), + ] + .concat() + { + let profile = match node.node_settings.profiles.get(profile_name) { + Some(x) => x, + None => { + return Err(RunDeployError::ProfileNotFound( + profile_name.to_owned(), + )) + } + }; + + if !profiles_list.iter().any(|(n, _)| n == profile_name) { + profiles_list.push((&profile_name, profile)); + } + } + + let ll: ToDeploy = profiles_list + .into_iter() + .map(|x| (deploy_flake, data, (node_name.as_str(), node), x)) + .collect(); + + l.extend(ll); + } + + l + } + (None, Some(_)) => return Err(RunDeployError::ProfileWithoutNode), + }; + Ok(to_deploys) + }) + .collect::<Result<Vec<ToDeploy>, RunDeployError>>()? + .into_iter() + .flatten() + .collect(); + + let mut parts: Vec<( + &deploy::DeployFlake<'_>, + deploy::DeployData, + deploy::DeployDefs, + )> = Vec::new(); + + for (deploy_flake, data, (node_name, node), (profile_name, profile)) in to_deploy { + let deploy_data = deploy::make_deploy_data( + &data.generic_settings, + node, + node_name, + profile, + profile_name, + &cmd_overrides, + debug_logs, + log_dir.as_deref(), + ); + + let deploy_defs = deploy_data.defs()?; + + parts.push((deploy_flake, deploy_data, deploy_defs)); + } + + if interactive { + prompt_deployment(&parts[..])?; + } else { + print_deployment(&parts[..])?; + } + + for (deploy_flake, deploy_data, deploy_defs) in &parts { + deploy::push::push_profile(deploy::push::PushProfileData { + supports_flakes, + check_sigs, + repo: deploy_flake.repo, + deploy_data: &deploy_data, + deploy_defs: &deploy_defs, + keep_result, + result_path, + extra_build_args, + }) + .await?; + } + + let mut succeeded: Vec<(&deploy::DeployData, &deploy::DeployDefs)> = vec![]; + + // Run all deployments + // In case of an error rollback any previoulsy made deployment. + // Rollbacks adhere to the global seeting to auto_rollback and secondary + // the profile's configuration + for (_, deploy_data, deploy_defs) in &parts { + if let Err(e) = deploy::deploy::deploy_profile(deploy_data, deploy_defs, dry_activate).await + { + error!("{}", e); + if dry_activate { + info!("dry run, not rolling back"); + } + info!("Revoking previous deploys"); + if rollback_succeeded && cmd_overrides.auto_rollback.unwrap_or(true) { + // revoking all previous deploys + // (adheres to profile configuration if not set explicitely by + // the command line) + for (deploy_data, deploy_defs) in &succeeded { + if deploy_data.merged_settings.auto_rollback.unwrap_or(true) { + deploy::deploy::revoke(*deploy_data, *deploy_defs).await?; + } + } + } + break; + } + succeeded.push((deploy_data, deploy_defs)) + } + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum RunError { + #[error("Failed to deploy profile: {0}")] + DeployProfile(#[from] deploy::deploy::DeployProfileError), + #[error("Failed to push profile: {0}")] + PushProfile(#[from] deploy::push::PushProfileError), + #[error("Failed to test for flake support: {0}")] + FlakeTest(std::io::Error), + #[error("Failed to check deployment: {0}")] + CheckDeployment(#[from] CheckDeploymentError), + #[error("Failed to evaluate deployment data: {0}")] + GetDeploymentData(#[from] GetDeploymentDataError), + #[error("Error parsing flake: {0}")] + ParseFlake(#[from] deploy::ParseFlakeError), + #[error("Error initiating logger: {0}")] + Logger(#[from] flexi_logger::FlexiLoggerError), + #[error("{0}")] + RunDeploy(#[from] RunDeployError), +} + +pub async fn run(args: Option<&ArgMatches>) -> Result<(), RunError> { + let opts = match args { + Some(o) => <Opts as FromArgMatches>::from_arg_matches(o), + None => Opts::parse(), + }; + + deploy::init_logger( + opts.debug_logs, + opts.log_dir.as_deref(), + deploy::LoggerType::Deploy, + )?; + + let deploys = opts + .clone() + .targets + .unwrap_or_else(|| vec![opts.clone().target.unwrap_or(".".to_string())]); + + let deploy_flakes: Vec<DeployFlake> = deploys + .iter() + .map(|f| deploy::parse_flake(f.as_str())) + .collect::<Result<Vec<DeployFlake>, ParseFlakeError>>()?; + + let cmd_overrides = deploy::CmdOverrides { + ssh_user: opts.ssh_user, + profile_user: opts.profile_user, + ssh_opts: opts.ssh_opts, + fast_connection: opts.fast_connection, + auto_rollback: opts.auto_rollback, + hostname: opts.hostname, + magic_rollback: opts.magic_rollback, + temp_path: opts.temp_path, + confirm_timeout: opts.confirm_timeout, + dry_activate: opts.dry_activate, + }; + + let supports_flakes = test_flake_support().await.map_err(RunError::FlakeTest)?; + + if !supports_flakes { + warn!("A Nix version without flakes support was detected, support for this is work in progress"); + } + + if !opts.skip_checks { + for deploy_flake in deploy_flakes.iter() { + check_deployment(supports_flakes, deploy_flake.repo, &opts.extra_build_args).await?; + } + } + let result_path = opts.result_path.as_deref(); + let data = get_deployment_data(supports_flakes, &deploy_flakes, &opts.extra_build_args).await?; + run_deploy( + deploy_flakes, + data, + supports_flakes, + opts.checksigs, + opts.interactive, + &cmd_overrides, + opts.keep_result, + result_path, + &opts.extra_build_args, + opts.debug_logs, + opts.dry_activate, + &opts.log_dir, + opts.rollback_succeeded.unwrap_or(true), + ) + .await?; + + Ok(()) +} @@ -148,6 +148,7 @@ pub fn init_logger( pub mod data; pub mod deploy; pub mod push; +pub mod cli; #[derive(Debug)] pub struct CmdOverrides { |