From 3ee97c2a7694995d7f2277004acc43f9bf0503cf Mon Sep 17 00:00:00 2001 From: "Jason R. McNeil" Date: Thu, 8 Apr 2021 01:14:28 -0700 Subject: [PATCH] apply: Add `deployment.replaceUnknownProfiles` option and `--force-replace-unknown-profiles` switch If `deployment.replaceUnknownProfiles` is set to false, a diverged hive config (in a shared git repo for example) won't result in accidentally undoing another applied configuration profile. The deployment option is set to true so that fiction is minimized from aggressive garbage collection, first time profile application and low contention hives. --- README.md | 13 +++++++++++++ src/command/apply.rs | 7 +++++++ src/nix/deployment.rs | 30 ++++++++++++++++++++++++++++-- src/nix/eval.nix | 16 ++++++++++++++++ src/nix/host/local.rs | 3 +++ src/nix/host/mod.rs | 5 ++++- src/nix/host/ssh.rs | 23 ++++++++++++++++++++++- src/nix/mod.rs | 6 ++++++ 8 files changed, 99 insertions(+), 4 deletions(-) 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, }