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.
This commit is contained in:
Jason R. McNeil 2021-04-08 01:14:28 -07:00
parent 0927fe9dc1
commit 3ee97c2a76
8 changed files with 99 additions and 4 deletions

View file

@ -61,6 +61,16 @@ Here is a sample `hive.nix` with two nodes, with some common configurations appl
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
vim wget curl 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, ... }: { 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. # environment variable to specify a ssh_config file.
deployment.targetPort = 1234; deployment.targetPort = 1234;
# Override the default for this target host
deployment.replaceUnknownProfiles = false;
time.timeZone = "America/Los_Angeles"; time.timeZone = "America/Los_Angeles";
boot.loader.grub.device = "/dev/sda"; boot.loader.grub.device = "/dev/sda";

View file

@ -91,6 +91,12 @@ To upload keys without building or deploying the rest of the configuration, use
.help("Do not use gzip") .help("Do not use gzip")
.long_help("Disables the use of gzip when copying closures to the remote host.") .long_help("Disables the use of gzip when copying closures to the remote host.")
.takes_value(false)) .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> { 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_gzip(!local_args.is_present("no-gzip"));
options.set_progress_bar(!local_args.is_present("verbose")); options.set_progress_bar(!local_args.is_present("verbose"));
options.set_upload_keys(!local_args.is_present("no-keys")); 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") { if local_args.is_present("keep-result") {
options.set_gc_roots(hive_base.join(".gcroots")); options.set_gc_roots(hive_base.join(".gcroots"));

View file

@ -10,10 +10,10 @@ use tokio::sync::{Mutex, Semaphore};
use super::{Hive, Host, CopyOptions, NodeConfig, Profile, StoreDerivation, ProfileMap, host}; use super::{Hive, Host, CopyOptions, NodeConfig, Profile, StoreDerivation, ProfileMap, host};
use crate::progress::{Progress, TaskProgress, OutputStyle}; 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; 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 EVAL_PER_HOST_MB: u64 = 512;
const BATCH_OPERATION_LABEL: &'static str = "(...)"; const BATCH_OPERATION_LABEL: &'static str = "(...)";
@ -493,6 +493,24 @@ impl Deployment {
let mut bar = multi.create_task_progress(name.to_string()); 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() { if self.options.upload_keys && !target.config.keys.is_empty() {
bar.log("Uploading keys..."); bar.log("Uploading keys...");
@ -609,6 +627,9 @@ pub struct DeploymentOptions {
/// Directory to create GC roots for node profiles in. /// Directory to create GC roots for node profiles in.
gc_roots: Option<PathBuf>, gc_roots: Option<PathBuf>,
/// Ignore the node-level `deployment.replaceUnknownProfiles` option.
force_replace_unknown_profiles: bool,
} }
impl Default for DeploymentOptions { impl Default for DeploymentOptions {
@ -619,6 +640,7 @@ impl Default for DeploymentOptions {
gzip: true, gzip: true,
upload_keys: true, upload_keys: true,
gc_roots: None, gc_roots: None,
force_replace_unknown_profiles: false,
} }
} }
} }
@ -644,6 +666,10 @@ impl DeploymentOptions {
self.gc_roots = Some(path.as_ref().to_owned()); 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 { fn to_copy_options(&self) -> CopyOptions {
let options = CopyOptions::default(); let options = CopyOptions::default();

View file

@ -120,6 +120,22 @@ let
type = types.attrsOf keyType; type = types.attrsOf keyType;
default = {}; 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;
};
}; };
}; };
}; };

View file

@ -92,6 +92,9 @@ impl Host for Local {
result result
} }
async fn active_derivation_known(&mut self) -> NixResult<bool> {
Ok(true)
}
fn set_progress_bar(&mut self, bar: TaskProgress) { fn set_progress_bar(&mut self, bar: TaskProgress) {
self.progress_bar = bar; self.progress_bar = bar;
} }

View file

@ -102,7 +102,10 @@ pub trait Host: Send + Sync + std::fmt::Debug {
Err(NixError::Unsupported) 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<bool>;
#[allow(unused_variables)]
/// Activates a system profile on the host, if it runs NixOS. /// 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. /// The profile must already exist on the host. You should probably use deploy instead.

View file

@ -7,7 +7,7 @@ use async_trait::async_trait;
use tokio::process::Command; use tokio::process::Command;
use super::{CopyDirection, CopyOptions, Host, key_uploader}; 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::util::CommandExecution;
use crate::progress::TaskProgress; use crate::progress::TaskProgress;
@ -70,6 +70,27 @@ impl Host for Ssh {
let command = self.ssh(&v); let command = self.ssh(&v);
self.run_command(command).await self.run_command(command).await
} }
async fn active_derivation_known(&mut self) -> NixResult<bool> {
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) { fn set_progress_bar(&mut self, bar: TaskProgress) {
self.progress_bar = bar; self.progress_bar = bar;
} }

View file

@ -68,6 +68,9 @@ pub enum NixError {
#[snafu(display("Invalid NixOS system profile"))] #[snafu(display("Invalid NixOS system profile"))]
InvalidProfile, InvalidProfile,
#[snafu(display("Unknown active profile: {}", store_path))]
ActiveProfileUnknown { store_path: String },
#[snafu(display("Nix Error: {}", message))] #[snafu(display("Nix Error: {}", message))]
Unknown { message: String }, Unknown { message: String },
} }
@ -105,6 +108,9 @@ pub struct NodeConfig {
allow_local_deployment: bool, allow_local_deployment: bool,
tags: Vec<String>, tags: Vec<String>,
#[serde(rename = "replaceUnknownProfiles")]
replace_unknown_profiles: bool,
#[validate(custom = "validate_keys")] #[validate(custom = "validate_keys")]
keys: HashMap<String, Key>, keys: HashMap<String, Key>,
} }