diff options
Diffstat (limited to 'src/bin')
-rw-r--r-- | src/bin/activate.rs | 471 | ||||
-rw-r--r-- | src/bin/deploy.rs | 564 |
2 files changed, 1035 insertions, 0 deletions
diff --git a/src/bin/activate.rs b/src/bin/activate.rs new file mode 100644 index 0000000..554702c --- /dev/null +++ b/src/bin/activate.rs @@ -0,0 +1,471 @@ +// SPDX-FileCopyrightText: 2020 Serokell <https://serokell.io/> +// SPDX-FileCopyrightText: 2020 Andreas Fuchs <asf@boinkor.net> +// +// SPDX-License-Identifier: MPL-2.0 + +use signal_hook::{consts::signal::SIGHUP, iterator::Signals}; + +use clap::Clap; + +use tokio::fs; +use tokio::process::Command; +use tokio::sync::mpsc; +use tokio::time::timeout; + +use std::time::Duration; + +use std::path::Path; + +use notify::{RecommendedWatcher, RecursiveMode, Watcher}; + +use thiserror::Error; + +#[macro_use] +extern crate log; + +#[macro_use] +extern crate serde_derive; + +/// Remote activation utility for deploy-rs +#[derive(Clap, Debug)] +#[clap(version = "1.0", author = "Serokell <https://serokell.io/>")] +struct Opts { + /// Print debug logs to output + #[clap(short, long)] + debug_logs: bool, + /// Directory to print logs to + #[clap(long)] + log_dir: Option<String>, + + /// Path for any temporary files that may be needed during activation + #[clap(long)] + temp_path: String, + + #[clap(subcommand)] + subcmd: SubCommand, +} + +#[derive(Clap, Debug)] +enum SubCommand { + Activate(ActivateOpts), + Wait(WaitOpts), +} + +/// Activate a profile +#[derive(Clap, Debug)] +struct ActivateOpts { + /// The closure to activate + closure: String, + /// The profile path to install into + profile_path: String, + + /// Maximum time to wait for confirmation after activation + #[clap(long)] + confirm_timeout: u16, + + /// Wait for confirmation after deployment and rollback if not confirmed + #[clap(long)] + magic_rollback: bool, + + /// Auto rollback if failure + #[clap(long)] + auto_rollback: bool, +} + +/// Activate a profile +#[derive(Clap, Debug)] +struct WaitOpts { + /// The closure to wait for + closure: String, +} + +#[derive(Error, Debug)] +pub enum DeactivateError { + #[error("Failed to execute the rollback command: {0}")] + RollbackError(std::io::Error), + #[error("The rollback resulted in a bad exit code: {0:?}")] + RollbackExitError(Option<i32>), + #[error("Failed to run command for listing generations: {0}")] + ListGenError(std::io::Error), + #[error("Command for listing generations resulted in a bad exit code: {0:?}")] + ListGenExitError(Option<i32>), + #[error("Error converting generation list output to utf8: {0}")] + DecodeListGenUtf8Error(#[from] std::string::FromUtf8Error), + #[error("Failed to run command for deleting generation: {0}")] + DeleteGenError(std::io::Error), + #[error("Command for deleting generations resulted in a bad exit code: {0:?}")] + DeleteGenExitError(Option<i32>), + #[error("Failed to run command for re-activating the last generation: {0}")] + ReactivateError(std::io::Error), + #[error("Command for re-activating the last generation resulted in a bad exit code: {0:?}")] + ReactivateExitError(Option<i32>), +} + +pub async fn deactivate(profile_path: &str) -> Result<(), DeactivateError> { + warn!("De-activating due to error"); + + let nix_env_rollback_exit_status = Command::new("nix-env") + .arg("-p") + .arg(&profile_path) + .arg("--rollback") + .status() + .await + .map_err(DeactivateError::RollbackError)?; + + match nix_env_rollback_exit_status.code() { + Some(0) => (), + a => return Err(DeactivateError::RollbackExitError(a)), + }; + + debug!("Listing generations"); + + let nix_env_list_generations_out = Command::new("nix-env") + .arg("-p") + .arg(&profile_path) + .arg("--list-generations") + .output() + .await + .map_err(DeactivateError::ListGenError)?; + + match nix_env_list_generations_out.status.code() { + Some(0) => (), + a => return Err(DeactivateError::ListGenExitError(a)), + }; + + let generations_list = String::from_utf8(nix_env_list_generations_out.stdout)?; + + let last_generation_line = generations_list + .lines() + .last() + .expect("Expected to find a generation in list"); + + let last_generation_id = last_generation_line + .split_whitespace() + .next() + .expect("Expected to get ID from generation entry"); + + debug!("Removing generation entry {}", last_generation_line); + warn!("Removing generation by ID {}", last_generation_id); + + let nix_env_delete_generation_exit_status = Command::new("nix-env") + .arg("-p") + .arg(&profile_path) + .arg("--delete-generations") + .arg(last_generation_id) + .status() + .await + .map_err(DeactivateError::DeleteGenError)?; + + match nix_env_delete_generation_exit_status.code() { + Some(0) => (), + a => return Err(DeactivateError::DeleteGenExitError(a)), + }; + + info!("Attempting to re-activate the last generation"); + + let re_activate_exit_status = Command::new(format!("{}/deploy-rs-activate", profile_path)) + .env("PROFILE", &profile_path) + .current_dir(&profile_path) + .status() + .await + .map_err(DeactivateError::ReactivateError)?; + + match re_activate_exit_status.code() { + Some(0) => (), + a => return Err(DeactivateError::ReactivateExitError(a)), + }; + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum ActivationConfirmationError { + #[error("Failed to create activation confirmation directory: {0}")] + CreateConfirmDirError(std::io::Error), + #[error("Failed to create activation confirmation file: {0}")] + CreateConfirmFileError(std::io::Error), + #[error("Failed to create file system watcher instance: {0}")] + CreateWatcherError(notify::Error), + #[error("Error forking process: {0}")] + ForkError(i32), + #[error("Could not watch for activation sentinel: {0}")] + WatcherError(#[from] notify::Error), +} + +#[derive(Error, Debug)] +pub enum DangerZoneError { + #[error("Timeout elapsed for confirmation")] + TimesUp, + #[error("inotify stream ended without activation confirmation")] + NoConfirmation, + #[error("inotify encountered an error: {0}")] + WatchError(notify::Error), +} + +async fn danger_zone( + mut events: mpsc::Receiver<Result<(), notify::Error>>, + confirm_timeout: u16, +) -> Result<(), DangerZoneError> { + info!("Waiting for confirmation event..."); + + match timeout(Duration::from_secs(confirm_timeout as u64), events.recv()).await { + Ok(Some(Ok(()))) => Ok(()), + Ok(Some(Err(e))) => Err(DangerZoneError::WatchError(e)), + Ok(None) => Err(DangerZoneError::NoConfirmation), + Err(_) => Err(DangerZoneError::TimesUp), + } +} + +pub async fn activation_confirmation( + profile_path: String, + temp_path: String, + confirm_timeout: u16, + closure: String, +) -> Result<(), ActivationConfirmationError> { + let lock_path = deploy::make_lock_path(&temp_path, &closure); + + debug!("Ensuring parent directory exists for canary file"); + + if let Some(parent) = Path::new(&lock_path).parent() { + fs::create_dir_all(parent) + .await + .map_err(ActivationConfirmationError::CreateConfirmDirError)?; + } + + debug!("Creating canary file"); + + fs::File::create(&lock_path) + .await + .map_err(ActivationConfirmationError::CreateConfirmFileError)?; + + debug!("Creating notify watcher"); + + let (deleted, done) = mpsc::channel(1); + + let mut watcher: RecommendedWatcher = + Watcher::new_immediate(move |res: Result<notify::event::Event, notify::Error>| { + let send_result = match res { + Ok(e) if e.kind == notify::EventKind::Remove(notify::event::RemoveKind::File) => { + debug!("Got worthy removal event, sending on channel"); + deleted.try_send(Ok(())) + } + Err(e) => { + debug!("Got error waiting for removal event, sending on channel"); + deleted.try_send(Err(e)) + } + Ok(_) => Ok(()), // ignore non-removal events + }; + + if let Err(e) = send_result { + error!("Could not send file system event to watcher: {}", e); + } + })?; + + watcher.watch(&lock_path, RecursiveMode::NonRecursive)?; + + if let Err(err) = danger_zone(done, confirm_timeout).await { + error!("Error waiting for confirmation event: {}", err); + + if let Err(err) = deactivate(&profile_path).await { + error!( + "Error de-activating due to another error waiting for confirmation, oh no...: {}", + err + ); + } + } + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum WaitError { + #[error("Error creating watcher for activation: {0}")] + Watcher(#[from] notify::Error), + #[error("Error waiting for activation: {0}")] + Waiting(#[from] DangerZoneError), +} +pub async fn wait(temp_path: String, closure: String) -> Result<(), WaitError> { + let lock_path = deploy::make_lock_path(&temp_path, &closure); + + let (created, done) = mpsc::channel(1); + + let mut watcher: RecommendedWatcher = { + // TODO: fix wasteful clone + let lock_path = lock_path.clone(); + + Watcher::new_immediate(move |res: Result<notify::event::Event, notify::Error>| { + let send_result = match res { + Ok(e) if e.kind == notify::EventKind::Create(notify::event::CreateKind::File) => { + match &e.paths[..] { + [x] if x == Path::new(&lock_path) => created.try_send(Ok(())), + _ => Ok(()), + } + } + Err(e) => created.try_send(Err(e)), + Ok(_) => Ok(()), // ignore non-removal events + }; + + if let Err(e) = send_result { + error!("Could not send file system event to watcher: {}", e); + } + })? + }; + + watcher.watch(&temp_path, RecursiveMode::NonRecursive)?; + + // Avoid a potential race condition by checking for existence after watcher creation + if fs::metadata(&lock_path).await.is_ok() { + watcher.unwatch(&temp_path)?; + return Ok(()); + } + + danger_zone(done, 60).await?; + + info!("Found canary file, done waiting!"); + + Ok(()) +} + +#[derive(Error, Debug)] +pub enum ActivateError { + #[error("Failed to execute the command for setting profile: {0}")] + SetProfileError(std::io::Error), + #[error("The command for setting profile resulted in a bad exit code: {0:?}")] + SetProfileExitError(Option<i32>), + + #[error("Failed to execute the activation script: {0}")] + RunActivateError(std::io::Error), + #[error("The activation script resulted in a bad exit code: {0:?}")] + RunActivateExitError(Option<i32>), + + #[error("There was an error de-activating after an error was encountered: {0}")] + DeactivateError(#[from] DeactivateError), + + #[error("Failed to get activation confirmation: {0}")] + ActivationConfirmationError(#[from] ActivationConfirmationError), +} + +pub async fn activate( + profile_path: String, + closure: String, + auto_rollback: bool, + temp_path: String, + confirm_timeout: u16, + magic_rollback: bool, +) -> Result<(), ActivateError> { + info!("Activating profile"); + + let nix_env_set_exit_status = Command::new("nix-env") + .arg("-p") + .arg(&profile_path) + .arg("--set") + .arg(&closure) + .status() + .await + .map_err(ActivateError::SetProfileError)?; + + match nix_env_set_exit_status.code() { + Some(0) => (), + a => { + if auto_rollback { + deactivate(&profile_path).await?; + } + return Err(ActivateError::SetProfileExitError(a)); + } + }; + + debug!("Running activation script"); + + let activate_status = match Command::new(format!("{}/deploy-rs-activate", profile_path)) + .env("PROFILE", &profile_path) + .current_dir(&profile_path) + .status() + .await + .map_err(ActivateError::RunActivateError) + { + Ok(x) => x, + Err(e) => { + if auto_rollback { + deactivate(&profile_path).await?; + } + return Err(e); + } + }; + + match activate_status.code() { + Some(0) => (), + a => { + if auto_rollback { + deactivate(&profile_path).await?; + } + return Err(ActivateError::RunActivateExitError(a)); + } + }; + + info!("Activation succeeded!"); + + if magic_rollback { + info!("Magic rollback is enabled, setting up confirmation hook..."); + + match activation_confirmation(profile_path.clone(), temp_path, confirm_timeout, closure) + .await + { + Ok(()) => {} + Err(err) => { + deactivate(&profile_path).await?; + return Err(ActivateError::ActivationConfirmationError(err)); + } + }; + } + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + // Ensure that this process stays alive after the SSH connection dies + let mut signals = Signals::new(&[SIGHUP])?; + std::thread::spawn(move || { + for sig in signals.forever() { + println!("Received NOHUP - ignoring..."); + } + }); + + let opts: Opts = Opts::parse(); + + deploy::init_logger( + opts.debug_logs, + opts.log_dir.as_deref(), + match opts.subcmd { + SubCommand::Activate(_) => deploy::LoggerType::Activate, + SubCommand::Wait(_) => deploy::LoggerType::Wait, + }, + )?; + + let r = match opts.subcmd { + SubCommand::Activate(activate_opts) => activate( + activate_opts.profile_path, + activate_opts.closure, + activate_opts.auto_rollback, + opts.temp_path, + activate_opts.confirm_timeout, + activate_opts.magic_rollback, + ) + .await + .map_err(|x| Box::new(x) as Box<dyn std::error::Error>), + + SubCommand::Wait(wait_opts) => wait(opts.temp_path, wait_opts.closure) + .await + .map_err(|x| Box::new(x) as Box<dyn std::error::Error>), + }; + + match r { + Ok(()) => (), + Err(err) => { + error!("{}", err); + std::process::exit(1) + } + } + + Ok(()) +} diff --git a/src/bin/deploy.rs b/src/bin/deploy.rs new file mode 100644 index 0000000..0381525 --- /dev/null +++ b/src/bin/deploy.rs @@ -0,0 +1,564 @@ +// SPDX-FileCopyrightText: 2020 Serokell <https://serokell.io/> +// +// SPDX-License-Identifier: MPL-2.0 + +use std::collections::HashMap; +use std::io::{stdin, stdout, Write}; + +use clap::Clap; + +use std::process::Stdio; +use tokio::process::Command; + +use thiserror::Error; + +#[macro_use] +extern crate log; + +#[macro_use] +extern crate serde_derive; + +/// Simple Rust rewrite of a simple Nix Flake deployment tool +#[derive(Clap, Debug)] +#[clap(version = "1.0", author = "Serokell <https://serokell.io/>")] +struct Opts { + /// The flake to deploy + #[clap(default_value = ".")] + flake: 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>, +} + +/// 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}")] + NixCheckError(#[from] std::io::Error), + #[error("Nix checking command resulted in a bad exit code: {0:?}")] + NixCheckExitError(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 c = match supports_flakes { + true => Command::new("nix"), + false => Command::new("nix-build"), + }; + + let mut check_command = match supports_flakes { + true => { + c.arg("flake") + .arg("check") + .arg(repo) + } + false => { + c.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 = check_command.arg(extra_arg); + } + + let check_status = check_command.status().await?; + + match check_status.code() { + Some(0) => (), + a => return Err(CheckDeploymentError::NixCheckExitError(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), +} + +/// Evaluates the Nix in the given `repo` and return the processed Data from it +async fn get_deployment_data( + supports_flakes: bool, + repo: &str, + extra_build_args: &[String], +) -> Result<deploy::data::Data, GetDeploymentDataError> { + info!("Evaluating flake in {}", repo); + + let mut c = match supports_flakes { + true => Command::new("nix"), + false => Command::new("nix-instantiate"), + }; + + let mut build_command = match supports_flakes { + true => { + c.arg("eval") + .arg("--json") + .arg(format!("{}#deploy", repo)) + } + false => { + 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", repo)) + } + }; + + for extra_arg in extra_build_args { + build_command = build_command.arg(extra_arg); + } + + let build_child = build_command + .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)?) +} + +#[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::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(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::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}")] + DeployProfileError(#[from] deploy::deploy::DeployProfileError), + #[error("Failed to push profile: {0}")] + PushProfileError(#[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}")] + DeployDataDefsError(#[from] deploy::DeployDataDefsError), + #[error("Failed to make printable TOML of deployment: {0}")] + TomlFormat(#[from] toml::ser::Error), + #[error("{0}")] + PromptDeploymentError(#[from] PromptDeploymentError), +} + +async fn run_deploy( + deploy_flake: deploy::DeployFlake<'_>, + data: 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, + log_dir: Option<String>, +) -> Result<(), RunDeployError> { + let to_deploy: Vec<((&str, &deploy::data::Node), (&str, &deploy::data::Profile))> = + 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 => return Err(RunDeployError::NodeNotFound(node_name.to_owned())), + }; + let profile = match node.node_settings.profiles.get(profile_name) { + Some(x) => x, + None => return Err(RunDeployError::ProfileNotFound(profile_name.to_owned())), + }; + + vec![((node_name, node), (profile_name, 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| ((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: Vec<((&str, &deploy::data::Node), (&str, &deploy::data::Profile))> = + profiles_list + .into_iter() + .map(|x| ((node_name.as_str(), node), x)) + .collect(); + + l.extend(ll); + } + + l + } + (None, Some(_)) => return Err(RunDeployError::ProfileWithoutNode), + }; + + let mut parts: Vec<(deploy::DeployData, deploy::DeployDefs)> = Vec::new(); + + for ((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_data, deploy_defs)); + } + + if interactive { + prompt_deployment(&parts[..])?; + } else { + print_deployment(&parts[..])?; + } + + for (deploy_data, deploy_defs) in &parts { + deploy::push::push_profile( + supports_flakes, + check_sigs, + deploy_flake.repo, + &deploy_data, + &deploy_defs, + keep_result, + result_path, + extra_build_args, + ) + .await?; + } + + for (deploy_data, deploy_defs) in &parts { + deploy::deploy::deploy_profile(&deploy_data, &deploy_defs).await?; + } + + Ok(()) +} + +#[derive(Error, Debug)] +enum RunError { + #[error("Failed to deploy profile: {0}")] + DeployProfileError(#[from] deploy::deploy::DeployProfileError), + #[error("Failed to push profile: {0}")] + PushProfileError(#[from] deploy::push::PushProfileError), + #[error("Failed to test for flake support: {0}")] + FlakeTestError(std::io::Error), + #[error("Failed to check deployment: {0}")] + CheckDeploymentError(#[from] CheckDeploymentError), + #[error("Failed to evaluate deployment data: {0}")] + GetDeploymentDataError(#[from] GetDeploymentDataError), + #[error("Error parsing flake: {0}")] + ParseFlakeError(#[from] deploy::ParseFlakeError), + #[error("Error initiating logger: {0}")] + LoggerError(#[from] flexi_logger::FlexiLoggerError), + #[error("{0}")] + RunDeployError(#[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 deploy_flake = deploy::parse_flake(opts.flake.as_str())?; + + 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, + }; + + let supports_flakes = test_flake_support() + .await + .map_err(RunError::FlakeTestError)?; + + if !supports_flakes { + warn!("A Nix version without flakes support was detected, support for this is work in progress"); + } + + if !opts.skip_checks { + check_deployment(supports_flakes, deploy_flake.repo, &opts.extra_build_args).await?; + } + + let data = + get_deployment_data(supports_flakes, deploy_flake.repo, &opts.extra_build_args).await?; + + let result_path = opts.result_path.as_deref(); + + run_deploy( + deploy_flake, + data, + supports_flakes, + opts.checksigs, + opts.interactive, + cmd_overrides, + opts.keep_result, + result_path, + &opts.extra_build_args, + opts.debug_logs, + opts.log_dir, + ) + .await?; + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + match run().await { + Ok(()) => (), + Err(err) => { + error!("{}", err); + std::process::exit(1); + } + } + + Ok(()) +} |