diff --git a/README.md b/README.md index ee8355c..02de5f7 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,16 @@ Here is a sample `hive.nix` with two nodes, with some common configurations appl environment.systemPackages = with pkgs; [ vim wget curl ]; + + # By default, Colmena will replace unknown remote profile + # (unknown means the profile isn't in the nix store on the + # host running Colmena) during apply (with the default goal, + # boot, and switch). + # If you share a hive with others, or use multiple machines, + # and are not careful to always commit/push/pull changes + # you can accidentaly overwrite a remote profile so in those + # scenarios you might want to change this default to false. + # deployment.replaceUnknownProfiles = true; }; host-a = { name, nodes, ... }: { @@ -87,6 +97,9 @@ Here is a sample `hive.nix` with two nodes, with some common configurations appl # environment variable to specify a ssh_config file. deployment.targetPort = 1234; + # Override the default for this target host + deployment.replaceUnknownProfiles = false; + time.timeZone = "America/Los_Angeles"; boot.loader.grub.device = "/dev/sda"; diff --git a/src/command/apply.rs b/src/command/apply.rs index 5438326..30fdc06 100644 --- a/src/command/apply.rs +++ b/src/command/apply.rs @@ -91,6 +91,12 @@ To upload keys without building or deploying the rest of the configuration, use .help("Do not use gzip") .long_help("Disables the use of gzip when copying closures to the remote host.") .takes_value(false)) + .arg(Arg::with_name("force-replace-unknown-profiles") + .long("force-replace-unknown-profiles") + .help("Ignore all targeted nodes deployment.replaceUnknownProfiles setting") + .long_help(r#"If `deployment.replaceUnknownProfiles` is set for a target, using this switch +will treat deployment.replaceUnknownProfiles as though it was set true and perform unknown profile replacement."#) + .takes_value(false)) } pub fn subcommand() -> App<'static, 'static> { @@ -186,6 +192,7 @@ pub async fn run(_global_args: &ArgMatches<'_>, local_args: &ArgMatches<'_>) { options.set_gzip(!local_args.is_present("no-gzip")); options.set_progress_bar(!local_args.is_present("verbose")); options.set_upload_keys(!local_args.is_present("no-keys")); + options.set_force_replace_unknown_profiles(local_args.is_present("force-replace-unknown-profiles")); if local_args.is_present("keep-result") { options.set_gc_roots(hive_base.join(".gcroots")); diff --git a/src/nix/deployment.rs b/src/nix/deployment.rs index 6ac29c2..18ad1b0 100644 --- a/src/nix/deployment.rs +++ b/src/nix/deployment.rs @@ -10,10 +10,10 @@ use tokio::sync::{Mutex, Semaphore}; use super::{Hive, Host, CopyOptions, NodeConfig, Profile, StoreDerivation, ProfileMap, host}; use crate::progress::{Progress, TaskProgress, OutputStyle}; -/// Amount of RAM reserved for the system, in MB. +/// Amount of RAM reserved for the system, in MB. const EVAL_RESERVE_MB: u64 = 1024; -/// Estimated amount of RAM needed to evaluate one host, in MB. +/// Estimated amount of RAM needed to evaluate one host, in MB. const EVAL_PER_HOST_MB: u64 = 512; const BATCH_OPERATION_LABEL: &'static str = "(...)"; @@ -493,6 +493,24 @@ impl Deployment { let mut bar = multi.create_task_progress(name.to_string()); + // FIXME: Would be nicer to check remote status before spending time evaluating/building + if !target.config.replace_unknown_profiles { + bar.log("Checking remote profile..."); + match target.host.active_derivation_known().await { + Ok(_) => { + bar.log("Remote profile known"); + } + Err(e) => { + if self.options.force_replace_unknown_profiles { + bar.log("warning: remote profile is unknown, but unknown profiles are being ignored"); + } else { + bar.failure(&format!("Failed: {}", e)); + return; + } + } + } + } + if self.options.upload_keys && !target.config.keys.is_empty() { bar.log("Uploading keys..."); @@ -609,6 +627,9 @@ pub struct DeploymentOptions { /// Directory to create GC roots for node profiles in. gc_roots: Option, + + /// Ignore the node-level `deployment.replaceUnknownProfiles` option. + force_replace_unknown_profiles: bool, } impl Default for DeploymentOptions { @@ -619,6 +640,7 @@ impl Default for DeploymentOptions { gzip: true, upload_keys: true, gc_roots: None, + force_replace_unknown_profiles: false, } } } @@ -644,6 +666,10 @@ impl DeploymentOptions { self.gc_roots = Some(path.as_ref().to_owned()); } + pub fn set_force_replace_unknown_profiles(&mut self, enable: bool) { + self.force_replace_unknown_profiles = enable; + } + fn to_copy_options(&self) -> CopyOptions { let options = CopyOptions::default(); diff --git a/src/nix/eval.nix b/src/nix/eval.nix index ffee416..cf04c0c 100644 --- a/src/nix/eval.nix +++ b/src/nix/eval.nix @@ -120,6 +120,22 @@ let type = types.attrsOf keyType; default = {}; }; + replaceUnknownProfiles = lib.mkOption { + description = '' + Allow a configuration to be applied to a host running a profile we + have no knowledge of. By setting this option to false, you reduce + the likelyhood of rolling back changes made via another Colmena user. + + Unknown profiles are usually the result of either: + - The node had a profile applied, locally or by another Colmena. + - The host running Colmena garbage-collecting the profile. + + To force profile replacement on all targeted nodes during apply, + use the flag `--force-replace-unknown-profiles`. + ''; + type = types.bool; + default = true; + }; }; }; }; diff --git a/src/nix/host/local.rs b/src/nix/host/local.rs index 2a092b6..6d25e2e 100644 --- a/src/nix/host/local.rs +++ b/src/nix/host/local.rs @@ -92,6 +92,9 @@ impl Host for Local { result } + async fn active_derivation_known(&mut self) -> NixResult { + Ok(true) + } fn set_progress_bar(&mut self, bar: TaskProgress) { self.progress_bar = bar; } diff --git a/src/nix/host/mod.rs b/src/nix/host/mod.rs index c3e54ec..440b42d 100644 --- a/src/nix/host/mod.rs +++ b/src/nix/host/mod.rs @@ -102,7 +102,10 @@ pub trait Host: Send + Sync + std::fmt::Debug { Err(NixError::Unsupported) } - #[allow(unused_variables)] + /// Check if the active profile is known to the host running Colmena + async fn active_derivation_known(&mut self) -> NixResult; + + #[allow(unused_variables)] /// Activates a system profile on the host, if it runs NixOS. /// /// The profile must already exist on the host. You should probably use deploy instead. diff --git a/src/nix/host/ssh.rs b/src/nix/host/ssh.rs index 20316a5..6af34e9 100644 --- a/src/nix/host/ssh.rs +++ b/src/nix/host/ssh.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use tokio::process::Command; use super::{CopyDirection, CopyOptions, Host, key_uploader}; -use crate::nix::{StorePath, Profile, Goal, NixResult, NixCommand, Key, SYSTEM_PROFILE}; +use crate::nix::{StorePath, Profile, Goal, NixResult, NixCommand, NixError, Key, SYSTEM_PROFILE}; use crate::util::CommandExecution; use crate::progress::TaskProgress; @@ -70,6 +70,27 @@ impl Host for Ssh { let command = self.ssh(&v); self.run_command(command).await } + async fn active_derivation_known(&mut self) -> NixResult { + let paths = self.ssh(&["realpath", SYSTEM_PROFILE]) + .capture_output() + .await; + + match paths { + Ok(paths) => { + for path in paths.lines().into_iter() { + let remote_profile: StorePath = path.to_string().try_into().unwrap(); + if remote_profile.exists() { + return Ok(true); + } + return Err(NixError::ActiveProfileUnknown { + store_path: path.to_string(), + }); + } + return Ok(false); + } + Err(e) => Err(e), + } + } fn set_progress_bar(&mut self, bar: TaskProgress) { self.progress_bar = bar; } diff --git a/src/nix/mod.rs b/src/nix/mod.rs index 25c2b65..4a1f88a 100644 --- a/src/nix/mod.rs +++ b/src/nix/mod.rs @@ -68,6 +68,9 @@ pub enum NixError { #[snafu(display("Invalid NixOS system profile"))] InvalidProfile, + #[snafu(display("Unknown active profile: {}", store_path))] + ActiveProfileUnknown { store_path: String }, + #[snafu(display("Nix Error: {}", message))] Unknown { message: String }, } @@ -105,6 +108,9 @@ pub struct NodeConfig { allow_local_deployment: bool, tags: Vec, + #[serde(rename = "replaceUnknownProfiles")] + replace_unknown_profiles: bool, + #[validate(custom = "validate_keys")] keys: HashMap, }