aboutsummaryrefslogtreecommitdiff
path: root/src/bin
diff options
context:
space:
mode:
Diffstat (limited to 'src/bin')
-rw-r--r--src/bin/activate.rs471
-rw-r--r--src/bin/deploy.rs564
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(())
+}