git.delta.rocks / jrsonnet / refs/commits / 69498f520d8e

difftreelog

feat on-demand secret generation

rxzprzppYaroslav Bolyukin2026-01-22parent: #faec707.patch.diff
in: trunk

24 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1149,7 +1149,6 @@
  "base64 0.22.1",
  "serde",
  "unicode_categories",
- "z85",
 ]
 
 [[package]]
@@ -2089,6 +2088,7 @@
  "serde_json",
  "test-log",
  "thiserror 2.0.17",
+ "tokio",
  "tracing",
  "tracing-indicatif",
  "vte 0.15.0",
@@ -4638,12 +4638,6 @@
  "syn",
  "synstructure",
 ]
-
-[[package]]
-name = "z85"
-version = "3.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b3a41ce106832b4da1c065baa4c31cf640cf965fa1483816402b7f6b96f0a64"
 
 [[package]]
 name = "zerocopy"
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -7,8 +7,9 @@
 	host::{Config, DeployKind, GenerationStorage},
 	opts::FleetOpts,
 };
+use futures::{StreamExt as _, stream::FuturesUnordered};
 use nix_eval::nix_go;
-use tokio::task::{LocalSet, spawn_blocking};
+use tokio::task::spawn_blocking;
 use tracing::{Instrument, error, field, info, info_span, warn};
 
 #[derive(Parser)]
@@ -47,7 +48,7 @@
 				"--profile",
 				format!(
 					"/nix/var/nix/profiles/{}-{hostname}",
-					config.data().gc_root_prefix
+					config.data.gc_root_prefix
 				),
 			)
 			.arg(&out_output);
@@ -60,14 +61,14 @@
 impl BuildSystems {
 	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
 		let hosts = opts.filter_skipped(config.list_hosts()?)?;
-		let set = LocalSet::new();
+		let mut tasks = FuturesUnordered::new();
 		let build_attr = self.build_attr.clone();
 		for host in hosts {
 			let config = config.clone();
 			let span = info_span!("build", host = field::display(&host.name));
 			let hostname = host.name;
 			let build_attr = build_attr.clone();
-			set.spawn_local(
+			tasks.push(
 				(async move {
 					let built = match build_task(config, hostname.clone(), &build_attr).await {
 						Ok(path) => path,
@@ -88,7 +89,7 @@
 				.instrument(span),
 			);
 		}
-		set.await;
+		for _task in tasks.next().await {}
 		Ok(())
 	}
 }
@@ -96,7 +97,7 @@
 impl Deploy {
 	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {
 		let hosts = opts.filter_skipped(config.list_hosts()?)?;
-		let set = LocalSet::new();
+		let mut tasks = FuturesUnordered::new();
 		for host in hosts.into_iter() {
 			let config = config.clone();
 			let span = info_span!("deploy", host = field::display(&host.name));
@@ -112,7 +113,7 @@
 				host.set_legacy_ssh_store(legacy);
 			};
 
-			set.spawn_local(
+			tasks.push(
 				(async move {
 					let built = match build_task(config.clone(), hostname.clone(), "toplevel-fleet")
 						.await
@@ -170,7 +171,7 @@
 				.instrument(span),
 			);
 		}
-		set.await;
+		for _task in tasks.next().await {}
 		Ok(())
 	}
 }
modifiedcmds/fleet/src/cmds/rollback.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/rollback.rs
+++ b/cmds/fleet/src/cmds/rollback.rs
@@ -56,7 +56,7 @@
 		.collect::<HashSet<_>>();
 	let mut stored_locally = config
 		.local_host()
-		.list_generations(&format!("{}-{}", config.data().gc_root_prefix, host.name))
+		.list_generations(&format!("{}-{}", config.data.gc_root_prefix, host.name))
 		.await
 		.inspect_err(|e| {
 			warn!("failed to list generations available locally: {e}");
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -412,7 +412,7 @@
 					if opts.should_skip(&host)? {
 						continue;
 					}
-					config.key(&host.name).await?;
+					config.host_key(&host.name).await?;
 				}
 			}
 			Secret::Read {
@@ -421,6 +421,7 @@
 				part: part_name,
 				mut prefer_identities,
 			} => {
+				/*
 				let Some(secret) = config.shared_secret(&name) else {
 					bail!("secret doesn't exists");
 				};
@@ -460,6 +461,8 @@
 					part.raw.data.clone()
 				};
 				stdout().write_all(&data)?;
+				*/
+				todo!()
 			}
 			Secret::Regenerate {
 				prefer_identities,
@@ -605,6 +608,7 @@
 				todo!()
 			}
 			Secret::List {} => {
+				/*
 				let _span = info_span!("loading secrets").entered();
 				let configured = config.list_configured_shared()?;
 				#[derive(Tabled)]
@@ -638,6 +642,8 @@
 					*/
 				}
 				// info!("loaded\n{}", Table::new(table).to_string())
+				*/
+				todo!()
 			}
 			Secret::Edit {
 				name,
@@ -645,7 +651,7 @@
 				part,
 				add,
 			} => {
-				let secret = config
+				/*let secret = config
 					.host_secret(&machine, &name)
 					.context("secret not found")?;
 				if let Some(data) = secret.secret.parts.get(&part) {
@@ -656,7 +662,8 @@
 					String::new()
 				} else {
 					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");
-				};
+				};*/
+				todo!()
 			}
 		}
 		Ok(())
modifiedcmds/fleet/src/cmds/tf.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/tf.rs
+++ b/cmds/fleet/src/cmds/tf.rs
@@ -72,9 +72,9 @@
 			let tf_data: TfData = serde_json::from_slice(&data.stdout)
 				.context("failed to parse terraform fleet output")?;
 
-			let mut data = config.data();
 			debug!("synchronized done = {tf_data:?}");
-			data.extra.insert(
+			let mut extra = config.data.extra.write().expect("no poisoning");
+			extra.insert(
 				"terraformHosts".to_owned(),
 				serde_json::to_value(tf_data.hosts).expect("should be valid extra"),
 			);
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -4,7 +4,7 @@
 // pub(crate) mod command;
 pub(crate) mod extra_args;
 
-use std::{env, ffi::OsString, process::ExitCode};
+use std::{env, ffi::OsString, process::ExitCode, sync::Arc};
 
 use anyhow::{Result, bail};
 use clap::{CommandFactory, Parser};
@@ -23,7 +23,9 @@
 use human_repr::HumanCount;
 #[cfg(feature = "indicatif")]
 use indicatif::{ProgressState, ProgressStyle};
-use nix_eval::{gc_register_my_thread, gc_unregister_my_thread, init_libraries};
+use nix_eval::{
+	gc_register_my_thread, gc_unregister_my_thread, init_libraries, init_tokio_for_nix,
+};
 use tracing::{Instrument, error, info, info_span};
 #[cfg(feature = "indicatif")]
 use tracing_indicatif::IndicatifLayer;
@@ -39,9 +41,9 @@
 			info!("nothing to prefetch: no prefetch directory");
 			return Ok(());
 		}
-		let tasks = <FuturesUnordered<LocalBoxFuture<Result<()>>>>::new();
+		let tasks = FuturesUnordered::new();
 		for entry in std::fs::read_dir(&prefetch_dir)? {
-			tasks.push(Box::pin(async {
+			tasks.push(async {
 				let entry = entry?;
 				if !entry.metadata()?.is_file() {
 					bail!("only files should exist in prefetch directory");
@@ -59,7 +61,7 @@
 				status.arg("store").arg("prefetch-file").arg(path);
 				status.run_nix_string().instrument(span).await?;
 				Ok(())
-			}));
+			});
 		}
 		tasks.try_collect::<Vec<()>>().await?;
 		Ok(())
@@ -190,7 +192,7 @@
 
 	init_libraries();
 
-	tokio::runtime::Builder::new_multi_thread()
+	let runtime = tokio::runtime::Builder::new_multi_thread()
 		.enable_all()
 		.on_thread_start(|| {
 			gc_register_my_thread();
@@ -199,8 +201,13 @@
 			gc_unregister_my_thread();
 		})
 		.build()
-		.expect("failed to build runtime")
-		.block_on(async {
+		.expect("failed to build runtime");
+	let runtime = Arc::new(runtime);
+
+	init_tokio_for_nix(runtime.clone());
+
+	runtime.block_on(async {
+		tokio::task::spawn(async move {
 			if let Err(e) = main_real(opts).await {
 				error!("{e:#}");
 				ExitCode::FAILURE
@@ -208,6 +215,9 @@
 				ExitCode::SUCCESS
 			}
 		})
+		.await
+		.expect("primary task panicked")
+	})
 	// async_main(opts)
 }
 
modifiedcrates/fleet-base/src/command.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/command.rs
+++ b/crates/fleet-base/src/command.rs
@@ -334,7 +334,7 @@
 	let mut stderr = child.stderr.take().unwrap();
 	let stdout = child.stdout.take().unwrap();
 	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
-	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
+	let mut out: Option<Box<dyn AsyncRead + Unpin + Send>> = Some(Box::new(stdout));
 	let mut ob = want_stdout
 		.then(|| out.take().unwrap())
 		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
@@ -397,7 +397,7 @@
 	let mut stderr = child.stderr().take().unwrap();
 	let stdout = child.stdout().take().unwrap();
 	let mut err = FramedRead::new(&mut stderr, LinesCodec::new());
-	let mut out: Option<Box<dyn AsyncRead + Unpin>> = Some(Box::new(stdout));
+	let mut out: Option<Box<dyn AsyncRead + Unpin + Send>> = Some(Box::new(stdout));
 	let mut ob = want_stdout
 		.then(|| out.take().unwrap())
 		.unwrap_or_else(|| Box::new(EmptyAsyncRead));
modifiedcrates/fleet-base/src/deploy.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/deploy.rs
+++ b/crates/fleet-base/src/deploy.rs
@@ -78,7 +78,7 @@
 	// unit name conflict in systemd-run
 	// This code is tied to rollback.nix
 	if !disable_rollback && action.should_create_rollback_marker() {
-		let _span = info_span!("preparing").entered();
+		// let _span = info_span!("preparing").entered();
 		info!("preparing for rollback");
 		let generation = get_current_generation(host).await?;
 		info!(
@@ -179,7 +179,7 @@
 		// FIXME: Connection might be disconnected after activation run
 
 		if action.should_activate() && !failed {
-			let _span = info_span!("activating").entered();
+			// let _span = info_span!("activating").entered();
 			info!("executing activation script");
 			let specialised = if let Some(specialisation) = specialisation {
 				let mut specialised = built.join("specialisation");
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -1,10 +1,12 @@
 use std::{
+	cmp::Ordering,
 	collections::{
 		BTreeMap, BTreeSet,
 		btree_map::{self, Entry},
 	},
+	fmt,
 	io::{self, Cursor},
-	ops::Deref,
+	sync::RwLock,
 };
 
 use age::Recipient;
@@ -77,19 +79,18 @@
 	pub manager_keys: Vec<ManagerKey>,
 
 	#[serde(default)]
-	pub hosts: BTreeMap<String, HostData>,
+	pub hosts: RwLock<BTreeMap<String, HostData>>,
 
 	#[serde(default, alias = "shared_secrets")]
-	pub secrets: FleetSecrets,
+	pub secrets: RwLock<FleetSecrets>,
 
 	// extra_name => anything
 	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub extra: BTreeMap<String, Value>,
+	pub extra: RwLock<BTreeMap<String, Value>>,
 
 	#[serde(default)]
-	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+	#[serde(skip_serializing)]
+	host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,
 }
 impl FleetData {
 	pub fn from_str(s: &str) -> anyhow::Result<Self> {
@@ -97,6 +98,8 @@
 		if !data.host_secrets.is_empty() {
 			info!("migrating host secrets into shared secrets structure");
 			data.secrets
+				.write()
+				.expect("no poisoning")
 				.merge_from_hosts(std::mem::take(&mut data.host_secrets));
 		}
 		Ok(data)
@@ -130,128 +133,431 @@
 #[serde(rename_all = "camelCase")]
 #[must_use]
 pub struct FleetSecretData {
-	#[serde(default = "Utc::now")]
 	pub created_at: DateTime<Utc>,
-	#[serde(default)]
-	#[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]
+	#[serde(default, skip_serializing_if = "Option::is_none", alias = "expire_at")]
 	pub expires_at: Option<DateTime<Utc>>,
 
 	#[serde(flatten)]
 	pub parts: BTreeMap<String, FleetSecretPart>,
 
-	#[serde(default)]
-	#[serde(skip_serializing_if = "Value::is_null")]
+	#[serde(default, skip_serializing_if = "Value::is_null")]
 	pub generation_data: Value,
 }
 
+fn is_false(b: &bool) -> bool {
+	*b == false
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]
+#[repr(transparent)]
+pub struct SecretOwner(String);
+
+impl fmt::Display for SecretOwner {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		write!(f, "host:{}", self.0)
+	}
+}
+
+impl SecretOwner {
+	pub fn host(s: impl AsRef<str>) -> SecretOwner {
+		SecretOwner(s.as_ref().to_owned())
+	}
+	pub fn as_host(&self) -> Option<&str> {
+		Some(&self.0)
+	}
+}
+
 #[derive(Serialize, Deserialize, Clone, Debug)]
 #[serde(rename_all = "camelCase")]
 #[must_use]
 pub struct FleetSecretDistribution {
 	#[serde(default)]
-	pub owners: BTreeSet<String>,
+	owners: BTreeSet<SecretOwner>,
+	#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+	owners_pending_prune: BTreeMap<SecretOwner, String>,
+
 	#[serde(flatten)]
 	pub secret: FleetSecretData,
 
+	#[serde(default, skip_serializing_if = "Option::is_none")]
+	pending_prune: Option<String>,
 	#[serde(default, skip_serializing, alias = "managed")]
-	pub _deprecated_managed: bool,
+	_deprecated_managed: bool,
+}
+
+const EMPTY_PENDING_PRUNE: &BTreeMap<SecretOwner, String> = &BTreeMap::new();
+impl FleetSecretDistribution {
+	pub fn new(owners: BTreeSet<SecretOwner>, secret: FleetSecretData, now: DateTime<Utc>) -> Self {
+		assert!(
+			!owners.is_empty(),
+			"distribution should have at least one owner"
+		);
+		if let Some(expires_at) = &secret.expires_at {
+			assert!(
+				*expires_at > now,
+				"secret should not be expired on creation"
+			);
+		}
+		Self {
+			owners,
+			secret,
+			owners_pending_prune: BTreeMap::new(),
+			pending_prune: None,
+			_deprecated_managed: true,
+		}
+	}
+
+	fn owners_ex(&self, including_pruned: bool) -> impl Iterator<Item = &SecretOwner> {
+		let pending_prune = if including_pruned {
+			&self.owners_pending_prune
+		} else {
+			EMPTY_PENDING_PRUNE
+		};
+		self.owners.iter().chain(pending_prune.keys())
+	}
+	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {
+		self.owners_ex(false)
+	}
+
+	pub fn prune(&mut self, reason: String) {
+		assert!(
+			self.pending_prune.is_none(),
+			"it shouldn't be possible to prune the same distribution twice using public api"
+		);
+		self.pending_prune = Some(reason);
+	}
+	pub fn prune_owners(&mut self, owners: &BTreeSet<SecretOwner>, reason: String) {
+		// if self.owners.iter().all(|o| owners.contains(o)) && self.owners_pending_prune.is_empty() {
+		// 	self.prune(format!("all owners were pruned: {reason}"));
+		// 	return;
+		// }
+		for owner in owners {
+			if self.owners.remove(owner) {
+				self.owners_pending_prune
+					.insert(owner.to_owned(), reason.clone());
+			}
+		}
+		// if self.owners.is_empty() {
+		// 	self.prune("no owners left".to_owned());
+		// }
+	}
+	pub fn unprune_owner(&mut self, owner: SecretOwner) {
+		if self.owners_pending_prune.remove(&owner).is_some() {
+			self.owners.insert(owner);
+		}
+	}
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug, Default)]
 #[must_use]
-pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);
+pub struct FleetSecretDistributions {
+	stored: Vec<FleetSecretDistribution>,
+}
 
-impl Deref for FleetSecretDistributions {
-	type Target = [FleetSecretDistribution];
-
-	fn deref(&self) -> &Self::Target {
-		self.0.as_slice()
+fn compare_dists(
+	a: &FleetSecretDistribution,
+	b: &FleetSecretDistribution,
+	prefer_identities: &BTreeSet<SecretOwner>,
+	include_pruned_owners: bool,
+) -> Ordering {
+	use Ordering::*;
+	if prefer_identities.is_empty() {
+		let a_has = a
+			.owners_ex(include_pruned_owners)
+			.any(|o| prefer_identities.contains(o));
+		let b_has = b
+			.owners_ex(include_pruned_owners)
+			.any(|o| prefer_identities.contains(o));
+		match (a_has, b_has) {
+			(true, false) => return Greater,
+			(false, true) => return Less,
+			_ => {}
+		}
+	}
+	match (a.secret.expires_at, b.secret.expires_at) {
+		(None, Some(_)) => return Greater,
+		(Some(_), None) => return Less,
+		(Some(a), Some(b)) => {
+			// Later is better
+			return a.cmp(&b);
+		}
+		(None, None) => {}
 	}
+
+	// Which one is easier to access
+	return a.owners.len().cmp(&b.owners.len());
 }
 
 impl FleetSecretDistributions {
-	pub fn owners(&self) -> impl Iterator<Item = &String> {
-		self.0.iter().flat_map(|v| v.owners.iter())
+	/// Drop expired distributions
+	fn prune_expired(&mut self, now: DateTime<Utc>) {
+		for ele in self.distributions_mut() {
+			if let Some(expires_at) = ele.secret.expires_at {
+				if expires_at < now {
+					ele.prune(format!("expired during check at {now}"));
+				}
+			}
+		}
+	}
+	/// Perform all pruning relevant to shared secrets
+	/// Also see expected_owner_removed
+	pub fn prune_shared(
+		&mut self,
+		expected_owners: &BTreeSet<SecretOwner>,
+		unique: bool,
+		expected_parts: &BTreeMap<String, GeneratorPart>,
+		expected_generation_data: &Value,
+		regenerate_on_owner_removed: bool,
+		regenerate_on_owner_added: bool,
+		prefer_identities: &BTreeSet<SecretOwner>,
+		now: DateTime<Utc>,
+	) {
+		self.prune_expired(now);
+		self.prune_generation_data(expected_generation_data, None);
+		self.prune_missing_parts(expected_parts, None);
+
+		let current_owners = self.owners().cloned().collect::<BTreeSet<SecretOwner>>();
+
+		let mut to_add = expected_owners.difference(&current_owners);
+		if to_add.next().is_some() && unique && regenerate_on_owner_added {
+			for dist in self.distributions_mut() {
+				dist.prune(format!(
+					"owners missing, can't add new distribution, regeneration preferred"
+				));
+			}
+			return;
+		}
+
+		for to_remove in current_owners.difference(&expected_owners) {
+			self.entry(to_remove.clone()).remove(
+				regenerate_on_owner_removed,
+				"owner was removed from expected owners list, regenerate_on_owner_removed is set"
+					.to_string(),
+			);
+		}
+		if unique {
+			self.prune_nonunique(prefer_identities);
+		}
+	}
+	pub fn prune_host(
+		&mut self,
+		owner: SecretOwner,
+		expected_parts: &BTreeMap<String, GeneratorPart>,
+		expected_generation_data: &Value,
+		now: DateTime<Utc>,
+	) {
+		self.prune_expired(now);
+		self.prune_generation_data(expected_generation_data, Some(&owner));
+		// TODO: Owner-based pruning is warranted (e.g host no longer has secret defined)
+		self.prune_missing_parts(expected_parts, Some(&owner));
+	}
+	/// Position of best distributions as in iterator returned by distributions()
+	/// None if distributions not found
+	fn best_idx(
+		&self,
+		prefer_identities: &BTreeSet<SecretOwner>,
+		include_pruned_owners: bool,
+	) -> Option<usize> {
+		self.distributions()
+			.enumerate()
+			.max_by(|(_, a), (_, b)| {
+				compare_dists(&a, &b, prefer_identities, include_pruned_owners)
+			})
+			.map(|(p, _)| p)
+	}
+	/// Secret wants to be the same on all hosts, leave only one unpruned version of it
+	fn prune_nonunique(&mut self, prefer_identities: &BTreeSet<SecretOwner>) {
+		if self.distributions().next().is_none() {
+			return;
+		}
+		let best = self.best_idx(prefer_identities, false).expect("not empty");
+		for (i, dist) in self.distributions_mut().enumerate() {
+			if i != best {
+				dist.prune(
+					"secret wants to be the same on all hosts, only the best one was left"
+						.to_owned(),
+				);
+			}
+		}
+	}
+
+	pub fn try_unprune(&mut self, owner: SecretOwner) -> Option<&FleetSecretDistribution> {
+		assert!(self.get(&owner).is_none(), "secret is not pruned for host");
+		if let Some(dist) = self
+			.distributions_mut()
+			.find(|v| v.owners_pending_prune.contains_key(&owner))
+		{
+			dist.unprune_owner(owner);
+			Some(dist)
+		} else {
+			None
+		}
+	}
+
+	pub fn best_distribution_for_reencryption(
+		&mut self,
+		prefer_identities: &BTreeSet<SecretOwner>,
+	) -> Option<&mut FleetSecretDistribution> {
+		let best_idx = self.best_idx(prefer_identities, true)?;
+		self.distributions_mut().nth(best_idx)
+	}
+
+	fn prune_missing_parts(
+		&mut self,
+		expected_parts: &BTreeMap<String, GeneratorPart>,
+		filter_owner: Option<&SecretOwner>,
+	) {
+		'dist: for ele in self.distributions_mut() {
+			if let Some(filter_owner) = filter_owner {
+				if !ele.owners.contains(filter_owner) {
+					continue;
+				}
+				// Note: secret still can have multiple owners even if it is host-owned
+				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them
+			}
+			for (name, part) in expected_parts {
+				let Some(stored_part) = ele.secret.parts.get(name) else {
+					ele.prune(format!("secret definition added new part: {name}"));
+					continue 'dist;
+				};
+				if part.encrypted != stored_part.raw.encrypted {
+					ele.prune(format!(
+						"secret definition now requires part to be {}",
+						if part.encrypted {
+							"encrypted"
+						} else {
+							"non-encrypted"
+						}
+					));
+					continue 'dist;
+				}
+			}
+		}
+	}
+	fn prune_generation_data(
+		&mut self,
+		expected_generation_data: &Value,
+		filter_owner: Option<&SecretOwner>,
+	) {
+		for ele in self.distributions_mut() {
+			if let Some(filter_owner) = filter_owner {
+				if !ele.owners.contains(filter_owner) {
+					continue;
+				}
+				// Note: secret still can have multiple owners even if it is host-owned
+				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them
+			}
+			if ele.secret.generation_data != *expected_generation_data {
+				ele.prune(format!(
+					"expected generation data mismatch: {expected_generation_data:?}"
+				));
+			}
+		}
 	}
+
+	/// Prune all distributions with no unpruned owners.
+	/// For ease of reencryption where possible, it is only called on persistence, when in memory - pruned owners are kept and
+	/// can decrypt their secrets.
+	fn prune_dead(&mut self) {
+		for ele in self.distributions_mut() {
+			if ele.owners.is_empty() {
+				ele.prune("no owners left".to_owned());
+			}
+		}
+	}
+
+	pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {
+		self.stored.iter().filter(|v| v.pending_prune.is_none())
+	}
+	pub fn distributions_mut(&mut self) -> impl Iterator<Item = &mut FleetSecretDistribution> {
+		self.stored.iter_mut().filter(|v| v.pending_prune.is_none())
+	}
+	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {
+		self.distributions().flat_map(|v| v.owners.iter())
+	}
 	#[allow(
 		clippy::len_without_is_empty,
 		reason = "should not be empty for a long time"
 	)]
 	pub fn len(&self) -> usize {
-		self.0.len()
+		self.distributions().count()
 	}
 
-	pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {
-		self.0.iter().find(|d| d.owners.contains(owner))
+	pub fn get(&self, owner: &SecretOwner) -> Option<&FleetSecretDistribution> {
+		self.distributions().find(|d| d.owners.contains(owner))
 	}
-	fn entry(&mut self, owner: String) -> DistEntry<'_> {
-		let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {
+	fn entry(&mut self, owner: SecretOwner) -> DistEntry<'_> {
+		let Some((idx, dist)) = self
+			.distributions()
+			.enumerate()
+			.find(|(_, d)| d.owners.contains(&owner))
+		else {
 			return DistEntry::Vacant(VacantDistEntry {
 				distributions: self,
-				owner,
+				owners: BTreeSet::from([owner]),
 			});
 		};
 		DistEntry::Occupied(OccupiedDistEntry {
+			owners: dist.owners.clone(),
 			distributions: self,
 			idx,
-			owner,
 		})
 	}
-	fn extend(&mut self, dist: FleetSecretDistribution) {
-		for owner in &dist.owners {
-			self.entry(owner.to_owned()).remove();
+	pub fn extend(&mut self, dist: FleetSecretDistribution, reason: String) {
+		for ele in self.distributions_mut() {
+			ele.prune_owners(&dist.owners, reason.clone());
 		}
-		self.0.push(dist);
+		self.stored.push(dist);
 	}
-	pub fn contains(&self, owner: &str) -> bool {
-		self.0.iter().any(|d| d.owners.contains(owner))
+	pub fn contains(&self, owner: &SecretOwner) -> bool {
+		self.distributions().any(|d| d.owners.contains(owner))
 	}
 }
 
 struct OccupiedDistEntry<'d> {
 	distributions: &'d mut FleetSecretDistributions,
 	idx: usize,
-	owner: String,
+	owners: BTreeSet<SecretOwner>,
 }
 impl<'d> OccupiedDistEntry<'d> {
-	fn remove(self) -> VacantDistEntry<'d> {
-		let dist = &mut self.distributions.0[self.idx];
-		assert!(
-			dist.owners.remove(&self.owner),
-			"entry exists, as we have its reference"
-		);
-		if dist.owners.is_empty() {
-			self.distributions.0.remove(self.idx);
+	fn remove(self, whole_dist: bool, reason: String) -> VacantDistEntry<'d> {
+		let dist = &mut self.distributions.stored[self.idx];
+		if whole_dist {
+			dist.prune(reason);
+		} else {
+			dist.prune_owners(&self.owners, reason);
 		}
 		VacantDistEntry {
 			distributions: self.distributions,
-			owner: self.owner,
+			owners: self.owners,
 		}
 	}
-	fn set(self, secret: FleetSecretData) -> Self {
-		self.remove().set(secret)
+	fn set(self, secret: FleetSecretData, reason: String) -> Self {
+		self.remove(false, reason).set(secret)
 	}
 }
 struct VacantDistEntry<'d> {
 	distributions: &'d mut FleetSecretDistributions,
-	owner: String,
+	owners: BTreeSet<SecretOwner>,
 }
 impl<'d> VacantDistEntry<'d> {
 	fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {
 		let Self {
 			distributions,
-			owner,
+			owners,
 		} = self;
-		let idx = distributions.0.len();
-		distributions.0.push(FleetSecretDistribution {
-			owners: BTreeSet::from_iter([owner.clone()]),
+		let idx = distributions.stored.len();
+		distributions.stored.push(FleetSecretDistribution {
+			owners: owners.clone(),
 			secret,
 
+			owners_pending_prune: BTreeMap::new(),
+			pending_prune: None,
 			_deprecated_managed: true,
 		});
 		OccupiedDistEntry {
 			distributions,
-			owner,
+			owners,
 			idx,
 		}
 	}
@@ -262,16 +568,16 @@
 	Occupied(OccupiedDistEntry<'d>),
 }
 impl DistEntry<'_> {
-	fn remove(self) -> Self {
+	fn remove(self, whole_dist: bool, reason: String) -> Self {
 		match self {
 			DistEntry::Vacant(_) => self,
-			DistEntry::Occupied(o) => Self::Vacant(o.remove()),
+			DistEntry::Occupied(o) => Self::Vacant(o.remove(whole_dist, reason)),
 		}
 	}
-	fn set(self, secret: FleetSecretData) -> Self {
+	fn set(self, secret: FleetSecretData, reason: String) -> Self {
 		Self::Occupied(match self {
 			DistEntry::Vacant(e) => e.set(secret),
-			DistEntry::Occupied(e) => e.set(secret),
+			DistEntry::Occupied(e) => e.set(secret, reason),
 		})
 	}
 }
@@ -281,8 +587,13 @@
 	where
 		S: serde::Serializer,
 	{
+		let mut v = self.clone();
+		v.prune_dead();
 		let mut found_hosts = BTreeSet::new();
-		for ele in self.0.iter() {
+		for ele in v.distributions() {
+			if ele.pending_prune.is_some() {
+				continue;
+			}
 			if ele.owners.is_empty() {
 				panic!("consistency: secret distribution has no defined owners");
 			}
@@ -294,10 +605,15 @@
 				}
 			}
 		}
-		match self.0.len() {
+		match v.stored.len() {
 			0 => panic!("consistency: empty distributions"),
-			1 => self.0[0].serialize(serializer),
-			_ => self.0.serialize(serializer),
+			1 => v.stored[0].serialize(serializer),
+			_ => {
+				let mut sorted = v.stored.clone();
+				// Store outdated distributions last
+				sorted.sort_by_key(|v| v.pending_prune.is_some() as u32);
+				sorted.serialize(serializer)
+			}
 		}
 	}
 }
@@ -313,15 +629,18 @@
 			Many(Vec<FleetSecretDistribution>),
 		}
 		let d = Distributions::deserialize(deserializer)?;
-		let ds = match d {
+		let stored = match d {
 			Distributions::One(d) => vec![d],
 			Distributions::Many(ds) => ds,
 		};
-		if ds.is_empty() {
+		if stored.is_empty() {
 			return Err(de::Error::custom("consistency: empty distributions"));
 		}
 		let mut found_hosts = BTreeSet::new();
-		for ele in ds.iter() {
+		for ele in stored.iter() {
+			if ele.pending_prune.is_some() {
+				continue;
+			}
 			if ele.owners.is_empty() {
 				return Err(de::Error::custom(
 					"consistency: secret distribution has no defined owners",
@@ -335,73 +654,65 @@
 				}
 			}
 		}
-		Ok(Self(ds))
+		Ok(Self { stored })
 	}
 }
 
-#[derive(Serialize, Deserialize, Default)]
+#[derive(Deserialize, Default)]
 pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);
 
+impl Serialize for FleetSecrets {
+	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+	where
+		S: serde::Serializer,
+	{
+		let data: BTreeMap<String, FleetSecretDistributions> = self
+			.0
+			.iter()
+			.filter(|(_, v)| !v.stored.is_empty())
+			.map(|(k, v)| (k.clone(), v.clone()))
+			.collect();
+
+		data.serialize(serializer)
+	}
+}
+
 impl FleetSecrets {
 	pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {
 		self.0.keys()
 	}
 
-	pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {
+	pub fn keys_for_owner(&self, owner: &SecretOwner) -> impl Iterator<Item = &String> {
 		self.0
 			.iter()
 			.filter(|(_, d)| d.contains(owner))
 			.map(|(n, _)| n)
 	}
 
-	pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {
-		let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {
-			return false;
-		};
-		let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {
-			return false;
-		};
-
-		dist.remove();
-
-		if dists.get().0.is_empty() {
-			dists.remove();
-		};
-
-		true
-	}
-	pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {
-		let e = self
-			.0
-			.entry(secret.to_owned())
-			.or_insert_with(|| FleetSecretDistributions(Default::default()));
-		e.entry(owner.to_owned()).set(data);
-	}
 	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {
 		match self.0.entry(secret) {
 			Entry::Vacant(e) => {
-				e.insert(FleetSecretDistributions(vec![data]));
+				e.insert(FleetSecretDistributions { stored: vec![data] });
 			}
 			Entry::Occupied(mut e) => {
 				let dists = e.get_mut();
-				dists.extend(data)
+				dists.extend(data, "secret data was replaced".to_owned())
 			}
 		}
-	}
-	pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {
-		let secret = self.0.get(secret)?;
-		secret.get(owner)
 	}
 	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {
 		self.0.get(secret)
 	}
+	pub fn get_mut(&mut self, secret: &str) -> Option<&mut FleetSecretDistributions> {
+		self.0.get_mut(secret)
+	}
 
-	pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {
-		let Some(secret) = self.0.get(secret) else {
-			return false;
-		};
-		secret.contains(owner)
+	pub fn get_or_create(&mut self, secret: &str) -> &mut FleetSecretDistributions {
+		self.0
+			.entry(secret.to_owned())
+			.or_insert(FleetSecretDistributions::default())
 	}
+
 	pub fn contains(&self, secret: &str) -> bool {
 		self.0.contains_key(secret)
 	}
@@ -411,7 +722,7 @@
 
 	fn merge_from_hosts(
 		&mut self,
-		host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
+		host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,
 	) {
 		for (host, host_secrets) in host_secrets {
 			for (secret_name, mut secret_data) in host_secrets {
@@ -420,11 +731,27 @@
 			}
 		}
 	}
+
+	pub fn prune_host(&mut self, host: &SecretOwner, expected_nonshared: BTreeSet<String>) {
+		for (name, dists) in self.0.iter_mut() {
+			if expected_nonshared.contains(name) {
+				continue;
+			}
+			for dist in dists.distributions_mut() {
+				if dist.owners.contains(host) {
+					dist.prune_owners(
+						&BTreeSet::from([host.to_owned()]),
+						"host no longer defines this secret".to_owned(),
+					);
+				}
+			}
+		}
+	}
 }
 
-#[derive(Debug)]
+#[derive(Debug, Clone)]
 pub struct Expectations {
-	pub owners: BTreeSet<String>,
+	pub owners: BTreeSet<SecretOwner>,
 	pub generation_data: serde_json::Value,
 	pub parts: BTreeMap<String, GeneratorPart>,
 }
@@ -432,3 +759,26 @@
 pub struct GeneratorPart {
 	pub encrypted: bool,
 }
+
+#[derive(Debug, Clone, Copy)]
+pub struct RegenerationConstraints {
+	pub allow_different: bool,
+	pub regenerate_on_owner_added: bool,
+	pub regenerate_on_owner_removed: bool,
+}
+impl RegenerationConstraints {
+	pub fn host_personal() -> Self {
+		Self {
+			allow_different: false,
+			regenerate_on_owner_added: true,
+			regenerate_on_owner_removed: true,
+		}
+	}
+	pub fn without_preferences(self) -> Self {
+		Self {
+			allow_different: self.allow_different,
+			regenerate_on_owner_added: false,
+			regenerate_on_owner_removed: false,
+		}
+	}
+}
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
before · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	collections::BTreeSet,4	ffi::{OsStr, OsString},5	fmt::Display,6	io::Write,7	ops::Deref,8	path::PathBuf,9	str::FromStr,10	sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{Context, Result, anyhow, bail, ensure};14use fleet_shared::SecretData;15use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, SessionBuilder};17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24	command::MyCommand,25	fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},26};2728pub struct FleetConfigInternals {29	/// Fleet project directory, containing fleet.nix file.30	pub directory: PathBuf,31	/// builtins.currentSystem32	pub local_system: String,33	pub data: Arc<Mutex<FleetData>>,34	pub nix_args: Vec<OsString>,35	/// fleet_config.config36	pub config_field: Value,37	/// flake.output38	pub flake_outputs: Value,39	// TODO: Remove with connectivity refactor40	pub localhost: String,4142	/// import nixpkgs {system = local};43	pub default_pkgs: Value,44	/// inputs.nixpkgs45	pub nixpkgs: Value,46}4748// TODO: Make field not pub49#[derive(Clone)]50pub struct Config(pub Arc<FleetConfigInternals>);5152impl Deref for Config {53	type Target = FleetConfigInternals;5455	fn deref(&self) -> &Self::Target {56		&self.057	}58}5960#[derive(Clone, Copy, Debug)]61pub enum EscalationStrategy {62	Sudo,63	Run0,64	Su,65}6667#[derive(Clone, PartialEq, Copy, Debug)]68pub enum DeployKind {69	/// NixOS => NixOS managed by fleet70	UpgradeToFleet,71	/// NixOS managed by fleet => NixOS managed by fleet72	Fleet,73	/// Remote host has /mnt, /mnt/boot mounted,74	/// generated config is added to fleet configuration.75	NixosInstall,76	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),77	/// generated config is added to fleet configuration,78	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.79	NixosLustrate,80}8182impl FromStr for DeployKind {83	type Err = anyhow::Error;84	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {85		match s {86			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),87			"fleet" => Ok(Self::Fleet),88			"nixos-install" => Ok(Self::NixosInstall),89			"nixos-lustrate" => Ok(Self::NixosLustrate),90			v => bail!(91				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""92			),93		}94	}95}96pub struct ConfigHost {97	config: Config,98	pub name: String,99	groups: OnceCell<Vec<String>>,100101	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it102	deploy_kind: OnceCell<DeployKind>,103	session_destination: OnceCell<String>,104	legacy_ssh_store: OnceCell<bool>,105106	pub host_config: Option<Value>,107	pub nixos_config: OnceCell<Value>,108	pub nixos_unchecked_config: OnceCell<Value>,109	pub pkgs_override: Option<Value>,110111	// TODO: Move command helpers away with connectivity refactor112	pub local: bool,113	pub session: OnceLock<Arc<openssh::Session>>,114}115116#[derive(Debug, Clone, Copy)]117pub enum GenerationStorage {118	Deployer,119	Machine,120	Pusher,121}122impl GenerationStorage {123	fn prefix(&self) -> &'static str {124		match self {125			GenerationStorage::Deployer => "deployer.",126			GenerationStorage::Machine => "",127			GenerationStorage::Pusher => "pusher.",128		}129	}130}131132#[derive(Tabled, Debug)]133pub struct Generation {134	#[tabled(rename = "ID", format("{}", self.rollback_id()))]135	pub id: u32,136	#[tabled(rename = "Current")]137	pub current: bool,138	#[tabled(rename = "Created at")]139	pub datetime: UtcDateTime,140	#[tabled(format = "{:?}")]141	pub store_path: PathBuf,142	#[tabled(skip)]143	pub location: GenerationStorage,144}145impl Generation {146	pub fn rollback_id(&self) -> String {147		format!("{}{}", self.location.prefix(), self.id)148	}149}150151fn parse_generation_line(g: &str) -> Option<Generation> {152	let mut parts = g.split_whitespace();153	let id = parts.next()?;154	let id: u32 = id.parse().ok()?;155	let date = parts.next()?;156	let time = parts.next()?;157	let current = if let Some(current) = parts.next() {158		if current == "(current)" {159			Some(true)160		} else {161			None162		}163	} else {164		Some(false)165	};166	let current = current?;167	if parts.next().is_some() {168		warn!("unexpected text after generation: {g}");169	}170171	let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")172		.expect("valid format");173	let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;174175	Some(Generation {176		id,177		current,178		datetime,179		store_path: PathBuf::new(),180		location: GenerationStorage::Machine,181	})182}183// TODO: Move command helpers away with connectivity refactor184impl ConfigHost {185	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {186		let mut cmd = self.cmd("nix-env").await?;187		cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))188			.arg("--list-generations")189			.env("TZ", "UTC");190		// Sudo is required because --list-generations tries to acquire profile lock191		let data = cmd.sudo().run_string().await?;192		let mut generations = data193			.split('\n')194			.map(|e| e.trim())195			.filter(|&l| !l.is_empty())196			.filter_map(|g| {197				let generation = parse_generation_line(g);198				if generation.is_none() {199					warn!("bad generation: {g}");200				};201				generation202			})203			.collect::<Vec<_>>();204		for ele in generations.iter_mut() {205			let mut cmd = self.cmd("readlink").await?;206			cmd.arg("--")207				.arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));208			let path = cmd.run_string().await?;209			ele.store_path = PathBuf::from(path.trim_end_matches("\n"));210		}211212		Ok(generations)213	}214215	pub fn set_session_destination(&self, dest: String) {216		self.session_destination217			.set(dest)218			.expect("session destination is already set")219	}220	pub fn set_deploy_kind(&self, kind: DeployKind) {221		self.deploy_kind222			.set(kind)223			.expect("deploy kind is already set");224	}225	pub fn set_legacy_ssh_store(&self, legacy: bool) {226		self.legacy_ssh_store227			.set(legacy)228			.expect("legacy ssh store is already set")229	}230	pub async fn deploy_kind(&self) -> Result<DeployKind> {231		if let Some(kind) = self.deploy_kind.get() {232			return Ok(*kind);233		}234		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {235			Ok(v) => v,236			Err(e) => {237				bail!("failed to query remote system kind: {e}");238			}239		};240		if !is_fleet_managed {241			bail!(242				"{}",243				indoc::indoc! {"244				host is not marked as managed by fleet245				if you're not trying to lustrate/install system from scratch,246				you should either247					1. manually create /etc/FLEET_HOST file on the target host,248					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet249					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos250			"}251			);252		}253		// TOCTOU is possible254		let _ = self.deploy_kind.set(DeployKind::Fleet);255		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))256	}257	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {258		// Prefer sudo, as run0 has some gotchas with polkit259		// and too many repeating prompts.260		if (self.find_in_path("sudo").await).is_ok() {261			return Ok(EscalationStrategy::Sudo);262		}263		if (self.find_in_path("run0").await).is_ok() {264			return Ok(EscalationStrategy::Run0);265		}266		Ok(EscalationStrategy::Su)267	}268	async fn open_session(&self) -> Result<Arc<openssh::Session>> {269		assert!(!self.local, "do not open ssh connection to local session");270		// FIXME: TOCTOU271		if let Some(session) = &self.session.get() {272			return Ok((*session).clone());273		};274		let mut session = SessionBuilder::default();275		session.control_persist(ControlPersist::ClosedAfterInitialConnection);276277		let dest = self.session_destination.get().unwrap_or(&self.name);278		let session = session279			.connect(&dest)280			.await281			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;282		let session = Arc::new(session);283		self.session.set(session.clone()).expect("TOCTOU happened");284		Ok(session)285	}286	pub async fn mktemp_dir(&self) -> Result<String> {287		let mut cmd = self.cmd("mktemp").await?;288		cmd.arg("-d");289		let path = cmd.run_string().await?;290		Ok(path.trim_end().to_owned())291	}292	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {293		let mut cmd = self.cmd("sh").await?;294		cmd.arg("-c")295			.arg("test -e \"$1\" && echo true || echo false")296			.arg("_")297			.arg(path);298		cmd.run_value().await299	}300	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {301		let mut cmd = self.cmd("cat").await?;302		cmd.arg(path);303		cmd.run_bytes().await304	}305	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {306		let mut cmd = self.cmd("cat").await?;307		cmd.arg(path);308		cmd.run_string().await309	}310	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {311		let mut cmd = self.cmd("ls").await?;312		cmd.arg(path);313		let out = cmd.run_string().await?;314		let mut lines = out.split('\n');315		if let Some(last) = lines.next_back() {316			ensure!(last.is_empty(), "output of ls should end with newline");317		}318		Ok(lines.map(ToOwned::to_owned).collect())319	}320	#[allow(dead_code)]321	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {322		let text = self.read_file_text(path).await?;323		Ok(serde_json::from_str(&text)?)324	}325	pub async fn read_env(&self, env: &str) -> Result<String> {326		let mut cmd = self.cmd("printenv").await?;327		cmd.arg(env);328		cmd.run_string().await329	}330	pub async fn find_in_path(&self, command: &str) -> Result<String> {331		// // `which` is not a part of coreutils, and it might not exist on machine.332		// let path = self.read_env("PATH").await?;333		// // Assuming delimiter is :, we don't work with windows host, this check will be much334		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)335		// for ele in path.split(':') {336		// 	let test_path = format!("{ele}/{cmd}");337		// 	test -x etc338		// }339		// let mut cmd = self.cmd("printenv").await?;340		// cmd.arg(env);341		// Ok(cmd.run_string().await?)342		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.343		let mut cmd = self344			.cmd_escalation(345				// Not used346				EscalationStrategy::Su,347				"which",348			)349			.await?;350		cmd.arg(command);351		cmd.run_string().await352	}353	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>354	where355		<D as FromStr>::Err: Display,356	{357		let text = self.read_file_text(path).await?;358		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))359	}360	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {361		self.cmd_escalation(self.escalation_strategy().await?, cmd)362			.await363	}364	pub async fn cmd_escalation(365		&self,366		escalation: EscalationStrategy,367		cmd: impl AsRef<OsStr>,368	) -> Result<MyCommand> {369		if self.local {370			Ok(MyCommand::new(escalation, cmd))371		} else {372			let session = self.open_session().await?;373			Ok(MyCommand::new_on(escalation, cmd, session))374		}375	}376	pub async fn nix_cmd(&self) -> Result<MyCommand> {377		let mut nix = self.cmd("nix").await?;378		nix.args([379			"--extra-experimental-features",380			"nix-command",381			"--extra-experimental-features",382			"flakes",383		]);384		Ok(nix)385	}386387	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {388		ensure!(data.encrypted, "secret is not encrypted");389		let mut cmd = self.cmd("fleet-install-secrets").await?;390		cmd.arg("decrypt").eqarg("--secret", data.to_string());391		let encoded = cmd392			.sudo()393			.run_string()394			.await395			.context("failed to call remote host for decrypt")?;396		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;397		ensure!(!data.encrypted, "secret came out encrypted");398		Ok(data.data)399	}400	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {401		ensure!(data.encrypted, "secret is not encrypted");402		let mut cmd = self.cmd("fleet-install-secrets").await?;403		cmd.arg("reencrypt").eqarg("--secret", data.to_string());404		for target in targets {405			let key = self.config.key(&target).await?;406			cmd.eqarg("--targets", key);407		}408		let encoded = cmd409			.sudo()410			.run_string()411			.await412			.context("failed to call remote host for decrypt")?;413		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;414		ensure!(data.encrypted, "secret came out not encrypted");415		Ok(data)416	}417	/// Returns path for futureproofing, as path might change i.e on conversion to CA418	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {419		if self.local {420			// Path is located locally, thus already trusted.421			return Ok(path.to_owned());422		}423		let mut nix = MyCommand::new(424			// Not used425			EscalationStrategy::Su,426			"nix",427		);428		nix.arg("copy").arg("--substitute-on-destination");429430		let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {431			"ssh"432		} else {433			"ssh-ng"434		};435436		match self.deploy_kind().await? {437			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {438				nix.comparg("--to", format!("{proto}://{}", self.name));439			}440			DeployKind::NixosInstall => {441				nix442					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon443					.arg("--no-check-sigs")444					.comparg(445						"--to",446						format!("{proto}://root@{}?remote-store=/mnt", self.name),447					);448			}449		}450		nix.arg(path);451		nix.run_nix().await.context("nix copy")?;452		Ok(path.to_owned())453	}454	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {455		let mut cmd = self.cmd("systemctl").await?;456		cmd.arg("stop").arg(name);457		cmd.sudo().run().await458	}459	pub async fn systemctl_start(&self, name: &str) -> Result<()> {460		let mut cmd = self.cmd("systemctl").await?;461		cmd.arg("start").arg(name);462		cmd.sudo().run().await463	}464465	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {466		let mut cmd = self.cmd("rm").await?;467		cmd.arg("-f").arg(path);468		if sudo {469			cmd = cmd.sudo()470		}471		cmd.run().await472	}473}474475struct HostSecretDefinition(Value);476477impl ConfigHost {478	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,479	// assuming getting tags always returns the same value.480	pub fn tags(&self) -> Result<Vec<String>> {481		if let Some(v) = self.groups.get() {482			return Ok(v.clone());483		}484		let Some(host_config) = &self.host_config else {485			return Ok(vec![]);486		};487		let tags: Vec<String> = nix_go_json!(host_config.tags);488489		let _ = self.groups.set(tags.clone());490491		Ok(tags)492	}493	pub fn nixos_config(&self) -> Result<Value> {494		if let Some(v) = self.nixos_config.get() {495			return Ok(v.clone());496		}497		let Some(host_config) = &self.host_config else {498			bail!("local host has no nixos_config");499		};500		let nixos_config = nix_go!(host_config.nixos.config);501		assert_warn("nixos config evaluation", &nixos_config)?;502503		let _ = self.nixos_config.set(nixos_config.clone());504505		Ok(nixos_config)506	}507	pub fn nixos_unchecked_config(&self) -> Result<Value> {508		if let Some(v) = self.nixos_unchecked_config.get() {509			return Ok(v.clone());510		}511		let Some(host_config) = &self.host_config else {512			bail!("local host has no nixos_config");513		};514		let nixos_config = nix_go!(host_config.nixos_unchecked.config);515516		let _ = self.nixos_unchecked_config.set(nixos_config.clone());517518		Ok(nixos_config)519	}520521	pub fn list_defined_secrets(&self) -> Result<Vec<String>> {522		let nixos = self.nixos_unchecked_config()?;523		let secrets = nix_go!(nixos.secrets);524		secrets.list_fields()525	}526527	/// Packages for this host, resolved with nixpkgs overlays528	pub fn pkgs(&self) -> Result<Value> {529		if let Some(value) = &self.pkgs_override {530			return Ok(value.clone());531		}532		let Some(host_config) = &self.host_config else {533			bail!("local host has no host_config");534		};535		// TODO: Should nixos.options be cached?536		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))537	}538}539540pub struct SharedSecretDefinition(Value);541impl SharedSecretDefinition {542	pub fn expected_owners(&self) -> Result<BTreeSet<String>> {543		let secret = &self.0;544		Ok(nix_go_json!(secret.expectedOwners))545	}546	pub fn generator(&self) -> Result<Value> {547		let secret = &self.0;548		Ok(nix_go!(secret.generator))549	}550}551552impl Config {553	pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {554		let config = &self.config_field;555		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);556		Ok(tagged)557	}558	pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {559		let mut out = BTreeSet::new();560		for owner in owners {561			if let Some(tag) = owner.strip_prefix('@') {562				let hosts = self.tagged_hostnames(tag)?;563				out.extend(hosts);564			} else {565				out.insert(owner);566			}567		}568		Ok(out)569	}570	pub fn local_host(&self) -> ConfigHost {571		ConfigHost {572			config: self.clone(),573			name: "<virtual localhost>".to_owned(),574			host_config: None,575			nixos_config: OnceCell::new(),576			nixos_unchecked_config: OnceCell::new(),577			groups: {578				let cell = OnceCell::new();579				let _ = cell.set(vec![]);580				cell581			},582			pkgs_override: Some(self.default_pkgs.clone()),583584			local: true,585			session: OnceLock::new(),586			deploy_kind: OnceCell::new(),587			session_destination: OnceCell::new(),588			legacy_ssh_store: OnceCell::new(),589		}590	}591592	pub fn host(&self, name: &str) -> Result<ConfigHost> {593		let config = &self.config_field;594		let host_config = nix_go!(config.hosts[{ name }]);595596		Ok(ConfigHost {597			config: self.clone(),598			name: name.to_owned(),599			host_config: Some(host_config),600			nixos_config: OnceCell::new(),601			nixos_unchecked_config: OnceCell::new(),602			groups: OnceCell::new(),603			pkgs_override: None,604605			// TODO: Remove with connectivit refactor606			local: self.localhost == name,607			session: OnceLock::new(),608			deploy_kind: OnceCell::new(),609			session_destination: OnceCell::new(),610			legacy_ssh_store: OnceCell::new(),611		})612	}613	pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {614		let config = &self.config_field;615		let names = nix_go!(config.hosts).list_fields()?;616		let mut out = vec![];617		for name in names {618			out.push(self.host(&name)?);619		}620		Ok(out)621	}622	// TODO: Replace usages with .host().nixos_config623	pub fn system_config(&self, host: &str) -> Result<Value> {624		let fleet_field = &self.config_field;625		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))626	}627628	/// Shared secrets configured in fleet.nix or in flake629	pub fn list_configured_shared(&self) -> Result<Vec<String>> {630		let config_field = &self.config_field;631		nix_go!(config_field.sharedSecrets).list_fields()632	}633	pub fn has_shared(&self, name: &str) -> bool {634		let data = self.data();635		data.secrets.contains(name)636	}637	pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {638		let mut data = self.data_mut();639		data.secrets.set_data(name, shared);640	}641	pub fn remove_shared(&self, secret: &str) {642		let mut data = self.data_mut();643		data.secrets.remove(secret);644	}645646	pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {647		let data = self.data_mut();648		data.secrets.keys_for_owner(host).cloned().collect()649	}650	pub fn list_secrets(&self) -> Vec<String> {651		let data = self.data_mut();652		data.secrets.keys().cloned().collect()653	}654655	pub fn has_secret(&self, host: &str, secret: &str) -> bool {656		let data = self.data();657		data.secrets.contains_for_owner(secret, host)658	}659	pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {660		let mut data = self.data_mut();661		data.secrets.set_single_data(secret, host, value);662	}663	pub fn remove_secret(&self, host: &str, secret: &str) {664		let mut data = self.data_mut();665		data.secrets.drop_owner_no_reencrypt(secret, host);666	}667668	pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {669		let data = self.data();670		data.secrets.get_single(secret, host).cloned()671	}672	pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {673		let data = self.data();674		data.secrets.get(secret).cloned()675	}676677	pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {678		let config = &self.config_field;679		let shared_secrets = nix_go!(config.secrets);680		if !shared_secrets.has_field(secret)? {681			return Ok(None);682		}683		Ok(Some(SharedSecretDefinition(nix_go!(684			shared_secrets[secret]685		))))686	}687688	// TODO: Should this be something modifiable from other processes?689	// E.g terraform provider might want to update FleetData (e.g secrets),690	// and current implementation assumes only one process holds current fleet.nix691	// Given that it is no longer needs to be a file for nix evaluation,692	// maybe it can be a .nix file for persistence, but accessible only693	// thru some shared state controller? Might it be stored in terraform694	// state provider?695	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {696		self.data.lock().unwrap()697	}698	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {699		self.data.lock().unwrap()700	}701	pub fn save(&self) -> Result<()> {702		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;703		let data = nixlike::serialize(&self.data() as &FleetData)?;704		tempfile.write_all(705			format!(706				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"707			)708			.as_bytes(),709		)?;710		let mut fleet_data_path = self.directory.clone();711		fleet_data_path.push("fleet.nix");712		tempfile.persist(fleet_data_path)?;713		Ok(())714	}715}
after · crates/fleet-base/src/host.rs
1use std::{2	collections::{BTreeMap, BTreeSet},3	ffi::{OsStr, OsString},4	fmt::Display,5	io::Write,6	ops::Deref,7	path::PathBuf,8	str::FromStr,9	sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use chrono::{DateTime, Utc};14use fleet_shared::SecretData;15use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, SessionBuilder};17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24	command::MyCommand,25	fleetdata::{26		FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,27	},28};2930pub struct FleetConfigInternals {31	pub prefer_identities: BTreeSet<SecretOwner>,32	pub now: DateTime<Utc>,3334	/// Fleet project directory, containing fleet.nix file.35	pub directory: PathBuf,36	/// builtins.currentSystem37	pub local_system: String,38	pub data: Arc<FleetData>,39	pub nix_args: Vec<OsString>,40	/// fleet_config.config41	pub config_field: Value,42	/// flake.output43	pub flake_outputs: Value,44	// TODO: Remove with connectivity refactor45	pub localhost: String,4647	/// import nixpkgs {system = local};48	pub default_pkgs: Value,49	/// inputs.nixpkgs50	pub nixpkgs: Value,51}5253// TODO: Make field not pub54#[derive(Clone)]55pub struct Config(pub Arc<FleetConfigInternals>);5657impl Deref for Config {58	type Target = FleetConfigInternals;5960	fn deref(&self) -> &Self::Target {61		&self.062	}63}6465#[derive(Clone, Copy, Debug)]66pub enum EscalationStrategy {67	Sudo,68	Run0,69	Su,70}7172#[derive(Clone, PartialEq, Copy, Debug)]73pub enum DeployKind {74	/// NixOS => NixOS managed by fleet75	UpgradeToFleet,76	/// NixOS managed by fleet => NixOS managed by fleet77	Fleet,78	/// Remote host has /mnt, /mnt/boot mounted,79	/// generated config is added to fleet configuration.80	NixosInstall,81	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),82	/// generated config is added to fleet configuration,83	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.84	NixosLustrate,85}8687impl FromStr for DeployKind {88	type Err = anyhow::Error;89	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {90		match s {91			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),92			"fleet" => Ok(Self::Fleet),93			"nixos-install" => Ok(Self::NixosInstall),94			"nixos-lustrate" => Ok(Self::NixosLustrate),95			v => bail!(96				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""97			),98		}99	}100}101pub struct ConfigHost {102	config: Config,103	pub name: String,104	groups: OnceLock<Vec<String>>,105106	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it107	deploy_kind: OnceLock<DeployKind>,108	session_destination: OnceLock<String>,109	legacy_ssh_store: OnceLock<bool>,110111	pub host_config: Option<Value>,112	pub nixos_config: OnceLock<Value>,113	pub nixos_unchecked_config: OnceLock<Value>,114	pub pkgs_override: Option<Value>,115116	// TODO: Move command helpers away with connectivity refactor117	pub local: bool,118	pub session: OnceLock<Arc<openssh::Session>>,119}120121#[derive(Debug, Clone, Copy)]122pub enum GenerationStorage {123	Deployer,124	Machine,125	Pusher,126}127impl GenerationStorage {128	fn prefix(&self) -> &'static str {129		match self {130			GenerationStorage::Deployer => "deployer.",131			GenerationStorage::Machine => "",132			GenerationStorage::Pusher => "pusher.",133		}134	}135}136137#[derive(Tabled, Debug)]138pub struct Generation {139	#[tabled(rename = "ID", format("{}", self.rollback_id()))]140	pub id: u32,141	#[tabled(rename = "Current")]142	pub current: bool,143	#[tabled(rename = "Created at")]144	pub datetime: UtcDateTime,145	#[tabled(format = "{:?}")]146	pub store_path: PathBuf,147	#[tabled(skip)]148	pub location: GenerationStorage,149}150impl Generation {151	pub fn rollback_id(&self) -> String {152		format!("{}{}", self.location.prefix(), self.id)153	}154}155156fn parse_generation_line(g: &str) -> Option<Generation> {157	let mut parts = g.split_whitespace();158	let id = parts.next()?;159	let id: u32 = id.parse().ok()?;160	let date = parts.next()?;161	let time = parts.next()?;162	let current = if let Some(current) = parts.next() {163		if current == "(current)" {164			Some(true)165		} else {166			None167		}168	} else {169		Some(false)170	};171	let current = current?;172	if parts.next().is_some() {173		warn!("unexpected text after generation: {g}");174	}175176	let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")177		.expect("valid format");178	let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;179180	Some(Generation {181		id,182		current,183		datetime,184		store_path: PathBuf::new(),185		location: GenerationStorage::Machine,186	})187}188// TODO: Move command helpers away with connectivity refactor189impl ConfigHost {190	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {191		let mut cmd = self.cmd("nix-env").await?;192		cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))193			.arg("--list-generations")194			.env("TZ", "UTC");195		// Sudo is required because --list-generations tries to acquire profile lock196		let data = cmd.sudo().run_string().await?;197		let mut generations = data198			.split('\n')199			.map(|e| e.trim())200			.filter(|&l| !l.is_empty())201			.filter_map(|g| {202				let generation = parse_generation_line(g);203				if generation.is_none() {204					warn!("bad generation: {g}");205				};206				generation207			})208			.collect::<Vec<_>>();209		for ele in generations.iter_mut() {210			let mut cmd = self.cmd("readlink").await?;211			cmd.arg("--")212				.arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));213			let path = cmd.run_string().await?;214			ele.store_path = PathBuf::from(path.trim_end_matches("\n"));215		}216217		Ok(generations)218	}219220	pub fn set_session_destination(&self, dest: String) {221		self.session_destination222			.set(dest)223			.expect("session destination is already set")224	}225	pub fn set_deploy_kind(&self, kind: DeployKind) {226		self.deploy_kind227			.set(kind)228			.expect("deploy kind is already set");229	}230	pub fn set_legacy_ssh_store(&self, legacy: bool) {231		self.legacy_ssh_store232			.set(legacy)233			.expect("legacy ssh store is already set")234	}235	pub async fn deploy_kind(&self) -> Result<DeployKind> {236		if let Some(kind) = self.deploy_kind.get() {237			return Ok(*kind);238		}239		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {240			Ok(v) => v,241			Err(e) => {242				bail!("failed to query remote system kind: {e}");243			}244		};245		if !is_fleet_managed {246			bail!(247				"{}",248				indoc::indoc! {"249				host is not marked as managed by fleet250				if you're not trying to lustrate/install system from scratch,251				you should either252					1. manually create /etc/FLEET_HOST file on the target host,253					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet254					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos255			"}256			);257		}258		// TOCTOU is possible259		let _ = self.deploy_kind.set(DeployKind::Fleet);260		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))261	}262	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {263		// Prefer sudo, as run0 has some gotchas with polkit264		// and too many repeating prompts.265		if (self.find_in_path("sudo").await).is_ok() {266			return Ok(EscalationStrategy::Sudo);267		}268		if (self.find_in_path("run0").await).is_ok() {269			return Ok(EscalationStrategy::Run0);270		}271		Ok(EscalationStrategy::Su)272	}273	async fn open_session(&self) -> Result<Arc<openssh::Session>> {274		assert!(!self.local, "do not open ssh connection to local session");275		// FIXME: TOCTOU276		if let Some(session) = &self.session.get() {277			return Ok((*session).clone());278		};279		let mut session = SessionBuilder::default();280		session.control_persist(ControlPersist::ClosedAfterInitialConnection);281282		let dest = self.session_destination.get().unwrap_or(&self.name);283		let session = session284			.connect(&dest)285			.await286			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;287		let session = Arc::new(session);288		self.session.set(session.clone()).expect("TOCTOU happened");289		Ok(session)290	}291	pub async fn mktemp_dir(&self) -> Result<String> {292		let mut cmd = self.cmd("mktemp").await?;293		cmd.arg("-d");294		let path = cmd.run_string().await?;295		Ok(path.trim_end().to_owned())296	}297	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {298		let mut cmd = self.cmd("sh").await?;299		cmd.arg("-c")300			.arg("test -e \"$1\" && echo true || echo false")301			.arg("_")302			.arg(path);303		cmd.run_value().await304	}305	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {306		let mut cmd = self.cmd("cat").await?;307		cmd.arg(path);308		cmd.run_bytes().await309	}310	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {311		let mut cmd = self.cmd("cat").await?;312		cmd.arg(path);313		cmd.run_string().await314	}315	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {316		let mut cmd = self.cmd("ls").await?;317		cmd.arg(path);318		let out = cmd.run_string().await?;319		let mut lines = out.split('\n');320		if let Some(last) = lines.next_back() {321			ensure!(last.is_empty(), "output of ls should end with newline");322		}323		Ok(lines.map(ToOwned::to_owned).collect())324	}325	#[allow(dead_code)]326	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {327		let text = self.read_file_text(path).await?;328		Ok(serde_json::from_str(&text)?)329	}330	pub async fn read_env(&self, env: &str) -> Result<String> {331		let mut cmd = self.cmd("printenv").await?;332		cmd.arg(env);333		cmd.run_string().await334	}335	pub async fn find_in_path(&self, command: &str) -> Result<String> {336		// // `which` is not a part of coreutils, and it might not exist on machine.337		// let path = self.read_env("PATH").await?;338		// // Assuming delimiter is :, we don't work with windows host, this check will be much339		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)340		// for ele in path.split(':') {341		// 	let test_path = format!("{ele}/{cmd}");342		// 	test -x etc343		// }344		// let mut cmd = self.cmd("printenv").await?;345		// cmd.arg(env);346		// Ok(cmd.run_string().await?)347		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.348		let mut cmd = self349			.cmd_escalation(350				// Not used351				EscalationStrategy::Su,352				"which",353			)354			.await?;355		cmd.arg(command);356		cmd.run_string().await357	}358	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>359	where360		<D as FromStr>::Err: Display,361	{362		let text = self.read_file_text(path).await?;363		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))364	}365	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {366		self.cmd_escalation(self.escalation_strategy().await?, cmd)367			.await368	}369	pub async fn cmd_escalation(370		&self,371		escalation: EscalationStrategy,372		cmd: impl AsRef<OsStr>,373	) -> Result<MyCommand> {374		if self.local {375			Ok(MyCommand::new(escalation, cmd))376		} else {377			let session = self.open_session().await?;378			Ok(MyCommand::new_on(escalation, cmd, session))379		}380	}381	pub async fn nix_cmd(&self) -> Result<MyCommand> {382		let mut nix = self.cmd("nix").await?;383		nix.args([384			"--extra-experimental-features",385			"nix-command",386			"--extra-experimental-features",387			"flakes",388		]);389		Ok(nix)390	}391392	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {393		ensure!(data.encrypted, "secret is not encrypted");394		let mut cmd = self.cmd("fleet-install-secrets").await?;395		cmd.arg("decrypt").eqarg("--secret", data.to_string());396		let encoded = cmd397			.sudo()398			.run_string()399			.await400			.context("failed to call remote host for decrypt")?;401		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;402		ensure!(!data.encrypted, "secret came out encrypted");403		Ok(data.data)404	}405	pub async fn reencrypt_distribution(406		&self,407		data: &FleetSecretDistribution,408		targets: BTreeSet<SecretOwner>,409		now: DateTime<Utc>,410	) -> Result<FleetSecretDistribution> {411		let mut parts = BTreeMap::new();412		for (part_name, part) in &data.secret.parts {413			parts.insert(414				part_name.clone(),415				if part.raw.encrypted {416					FleetSecretPart {417						raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,418					}419				} else {420					part.clone()421				},422			);423		}424		let secret = FleetSecretData {425			created_at: data.secret.created_at,426			expires_at: data.secret.expires_at,427			generation_data: data.secret.generation_data.clone(),428			parts,429		};430		Ok(FleetSecretDistribution::new(targets, secret, now))431	}432	pub async fn reencrypt(433		&self,434		data: SecretData,435		targets: BTreeSet<SecretOwner>,436	) -> Result<SecretData> {437		ensure!(data.encrypted, "secret is not encrypted");438		let mut cmd = self.cmd("fleet-install-secrets").await?;439		cmd.arg("reencrypt").eqarg("--secret", data.to_string());440		for target in targets {441			let key = self.config.key(&target).await?;442			cmd.eqarg("--targets", key);443		}444		let encoded = cmd445			.sudo()446			.run_string()447			.await448			.context("failed to call remote host for decrypt")?;449		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;450		ensure!(data.encrypted, "secret came out not encrypted");451		Ok(data)452	}453	/// Returns path for futureproofing, as path might change i.e on conversion to CA454	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {455		if self.local {456			// Path is located locally, thus already trusted.457			return Ok(path.to_owned());458		}459		let mut nix = MyCommand::new(460			// Not used461			EscalationStrategy::Su,462			"nix",463		);464		nix.arg("copy").arg("--substitute-on-destination");465466		let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {467			"ssh"468		} else {469			"ssh-ng"470		};471472		match self.deploy_kind().await? {473			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {474				nix.comparg("--to", format!("{proto}://{}", self.name));475			}476			DeployKind::NixosInstall => {477				nix478					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon479					.arg("--no-check-sigs")480					.comparg(481						"--to",482						format!("{proto}://root@{}?remote-store=/mnt", self.name),483					);484			}485		}486		nix.arg(path);487		nix.run_nix().await.context("nix copy")?;488		Ok(path.to_owned())489	}490	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {491		let mut cmd = self.cmd("systemctl").await?;492		cmd.arg("stop").arg(name);493		cmd.sudo().run().await494	}495	pub async fn systemctl_start(&self, name: &str) -> Result<()> {496		let mut cmd = self.cmd("systemctl").await?;497		cmd.arg("start").arg(name);498		cmd.sudo().run().await499	}500501	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {502		let mut cmd = self.cmd("rm").await?;503		cmd.arg("-f").arg(path);504		if sudo {505			cmd = cmd.sudo()506		}507		cmd.run().await508	}509}510511struct HostSecretDefinition(Value);512513impl ConfigHost {514	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,515	// assuming getting tags always returns the same value.516	pub fn tags(&self) -> Result<Vec<String>> {517		if let Some(v) = self.groups.get() {518			return Ok(v.clone());519		}520		let Some(host_config) = &self.host_config else {521			return Ok(vec![]);522		};523		let tags: Vec<String> = nix_go_json!(host_config.tags);524525		let _ = self.groups.set(tags.clone());526527		Ok(tags)528	}529	pub fn nixos_config(&self) -> Result<Value> {530		if let Some(v) = self.nixos_config.get() {531			return Ok(v.clone());532		}533		let Some(host_config) = &self.host_config else {534			bail!("local host has no nixos_config");535		};536		let nixos_config = nix_go!(host_config.nixos.config);537		assert_warn("nixos config evaluation", &nixos_config)?;538539		let _ = self.nixos_config.set(nixos_config.clone());540541		Ok(nixos_config)542	}543	pub fn nixos_unchecked_config(&self) -> Result<Value> {544		if let Some(v) = self.nixos_unchecked_config.get() {545			return Ok(v.clone());546		}547		let Some(host_config) = &self.host_config else {548			bail!("local host has no nixos_config");549		};550		let nixos_config = nix_go!(host_config.nixos_unchecked.config);551552		let _ = self.nixos_unchecked_config.set(nixos_config.clone());553554		Ok(nixos_config)555	}556557	pub fn list_defined_secrets(&self) -> Result<Vec<String>> {558		let nixos = self.nixos_unchecked_config()?;559		let secrets = nix_go!(nixos.secrets);560		secrets.list_fields()561	}562563	/// Packages for this host, resolved with nixpkgs overlays564	pub fn pkgs(&self) -> Result<Value> {565		if let Some(value) = &self.pkgs_override {566			return Ok(value.clone());567		}568		let Some(host_config) = &self.host_config else {569			bail!("local host has no host_config");570		};571		// TODO: Should nixos.options be cached?572		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))573	}574}575576#[derive(Clone)]577pub struct SharedSecretDefinition(Value);578impl SharedSecretDefinition {579	pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {580		let secret = &self.0;581		Ok(nix_go_json!(secret.expectedOwners))582	}583	pub fn allow_different(&self) -> Result<bool> {584		let secret = &self.0;585		Ok(nix_go_json!(secret.allowDifferent))586	}587	pub fn regenerate_on_owner_added(&self) -> Result<bool> {588		let secret = &self.0;589		Ok(nix_go_json!(secret.regenerateOnOwnerAdded))590	}591	pub fn regenerate_on_owner_removed(&self) -> Result<bool> {592		let secret = &self.0;593		Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))594	}595	pub fn generator(&self) -> Result<Value> {596		let secret = &self.0;597		Ok(nix_go!(secret.generator))598	}599}600601impl Config {602	pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {603		let config = &self.config_field;604		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);605		Ok(tagged)606	}607	pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {608		let mut out = BTreeSet::new();609		for owner in owners {610			if let Some(tag) = owner.strip_prefix('@') {611				let hosts = self.tagged_hostnames(tag)?;612				out.extend(hosts);613			} else {614				out.insert(owner);615			}616		}617		Ok(out)618	}619	pub fn local_host(&self) -> ConfigHost {620		ConfigHost {621			config: self.clone(),622			name: "<virtual localhost>".to_owned(),623			host_config: None,624			nixos_config: OnceLock::new(),625			nixos_unchecked_config: OnceLock::new(),626			groups: {627				let cell = OnceLock::new();628				let _ = cell.set(vec![]);629				cell630			},631			pkgs_override: Some(self.default_pkgs.clone()),632633			local: true,634			session: OnceLock::new(),635			deploy_kind: OnceLock::new(),636			session_destination: OnceLock::new(),637			legacy_ssh_store: OnceLock::new(),638		}639	}640641	pub fn host(&self, name: &str) -> Result<ConfigHost> {642		let config = &self.config_field;643		let host_config = nix_go!(config.hosts[{ name }]);644645		Ok(ConfigHost {646			config: self.clone(),647			name: name.to_owned(),648			host_config: Some(host_config),649			nixos_config: OnceLock::new(),650			nixos_unchecked_config: OnceLock::new(),651			groups: OnceLock::new(),652			pkgs_override: None,653654			// TODO: Remove with connectivit refactor655			local: self.localhost == name,656			session: OnceLock::new(),657			deploy_kind: OnceLock::new(),658			session_destination: OnceLock::new(),659			legacy_ssh_store: OnceLock::new(),660		})661	}662	pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {663		let config = &self.config_field;664		let names = nix_go!(config.hosts).list_fields()?;665		let mut out = vec![];666		for name in names {667			out.push(self.host(&name)?);668		}669		Ok(out)670	}671	// TODO: Replace usages with .host().nixos_config672	pub fn system_config(&self, host: &str) -> Result<Value> {673		let fleet_field = &self.config_field;674		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))675	}676677	pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {678		let config = &self.config_field;679		let shared_secrets = nix_go!(config.secrets);680		if !shared_secrets.has_field(secret)? {681			return Ok(None);682		}683		Ok(Some(SharedSecretDefinition(nix_go!(684			shared_secrets[secret]685		))))686	}687688	pub fn save(&self) -> Result<()> {689		let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;690		let data = nixlike::serialize(&*self.data)?;691		tempfile.write_all(692			format!(693				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"694			)695			.as_bytes(),696		)?;697		let mut fleet_data_path = self.directory.clone();698		fleet_data_path.push("fleet.nix");699		tempfile.persist(fleet_data_path)?;700		Ok(())701	}702}
modifiedcrates/fleet-base/src/keys.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/keys.rs
+++ b/crates/fleet-base/src/keys.rs
@@ -1,17 +1,17 @@
 use std::str::FromStr as _;
 
 use age::Recipient;
-use anyhow::{Result, anyhow};
+use anyhow::{Result, anyhow, bail};
 use futures::{StreamExt as _, TryStreamExt as _};
 use itertools::Itertools as _;
 use tracing::warn;
 
-use crate::host::Config;
+use crate::{fleetdata::SecretOwner, host::Config};
 
 impl Config {
-	pub fn cached_key(&self, host: &str) -> Option<String> {
-		let data = self.data();
-		let key = data.hosts.get(host).map(|h| &h.encryption_key);
+	fn cached_host_key(&self, host: &str) -> Option<String> {
+		let hosts = self.data.hosts.read().expect("no poisoning");
+		let key = hosts.get(host).map(|h| &h.encryption_key);
 		if let Some(key) = key
 			&& key.is_empty()
 		{
@@ -20,13 +20,13 @@
 		key.cloned()
 	}
 	pub fn update_key(&self, host: &str, key: String) {
-		let mut data = self.data_mut();
-		let host = data.hosts.entry(host.to_string()).or_default();
+		let mut hosts = self.data.hosts.write().expect("no poisoning");
+		let host = hosts.entry(host.to_string()).or_default();
 		host.encryption_key = key.trim().to_string();
 	}
 
-	pub async fn key(&self, host: &str) -> anyhow::Result<String> {
-		if let Some(key) = self.cached_key(host) {
+	pub async fn host_key(&self, host: &str) -> anyhow::Result<String> {
+		if let Some(key) = self.cached_host_key(host) {
 			Ok(key)
 		} else {
 			warn!("Loading key for {}", host);
@@ -38,18 +38,24 @@
 			Ok(key)
 		}
 	}
+	pub async fn key(&self, owner: &SecretOwner) -> anyhow::Result<String> {
+		if let Some(host) = owner.as_host() {
+			self.host_key(host).await
+		} else {
+			bail!("only host keys supported for now")
+		}
+	}
 	/// Insecure, requires root
-	pub async fn recipient(&self, host: &str) -> anyhow::Result<Box<dyn Recipient>> {
+	pub async fn recipient(&self, host: &SecretOwner) -> anyhow::Result<Box<dyn Recipient>> {
 		let key = self.key(host).await?;
 		age::ssh::Recipient::from_str(&key)
 			.map_err(|e| anyhow!("parse recipient error: {e:?}"))
 			.map(|v| Box::new(v) as Box<dyn Recipient>)
 	}
 
-	pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<Box<dyn Recipient>>> {
-		let hosts = self.expand_owner_set(hosts)?;
+	pub async fn recipients(&self, hosts: Vec<SecretOwner>) -> Result<Vec<Box<dyn Recipient>>> {
 		futures::stream::iter(hosts.iter())
-			.then(|m| self.recipient(m.as_ref()))
+			.then(|m| self.recipient(m))
 			.try_collect::<Vec<_>>()
 			.await
 	}
@@ -58,9 +64,8 @@
 	pub async fn orphaned_data(&self) -> Result<Vec<String>> {
 		let mut out = Vec::new();
 		let host_names = self.list_hosts()?.into_iter().map(|h| h.name).collect_vec();
-		for hostname in self
-			.data()
-			.hosts
+		let hosts = self.data.hosts.read().expect("no poisoning");
+		for hostname in hosts
 			.iter()
 			.filter(|(_, host)| !host.encryption_key.is_empty())
 			.map(|(n, _)| n)
modifiedcrates/fleet-base/src/lib.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/lib.rs
+++ b/crates/fleet-base/src/lib.rs
@@ -5,5 +5,4 @@
 mod keys;
 pub mod opts;
 pub mod primops;
-pub mod secret;
 pub mod secret_storage;
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -1,5 +1,5 @@
 use std::{
-	collections::BTreeMap,
+	collections::{BTreeMap, BTreeSet},
 	env::current_dir,
 	ffi::OsString,
 	str::FromStr,
@@ -7,6 +7,7 @@
 };
 
 use anyhow::{Context, Result, bail};
+use chrono::Utc;
 use nix_eval::{
 	FetchSettings, FlakeLockFlags, FlakeReference, FlakeReferenceParseFlags, FlakeSettings, Value,
 	gc_now, nix_go, util::assert_warn,
@@ -212,7 +213,7 @@
 		}
 		let bytes =
 			std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;
-		let data = Arc::new(Mutex::new(FleetData::from_str(&bytes)?));
+		let data = Arc::new(FleetData::from_str(&bytes)?);
 
 		init_primops();
 
@@ -265,6 +266,10 @@
 			gc_now();
 		}
 		let config = Config(Arc::new(FleetConfigInternals {
+			// TODO: Load from somewhere
+			prefer_identities: BTreeSet::new(),
+			now: Utc::now(),
+
 			directory,
 			data,
 			flake_outputs: flake,
modifiedcrates/fleet-base/src/primops.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/primops.rs
+++ b/crates/fleet-base/src/primops.rs
@@ -4,19 +4,16 @@
 use anyhow::{Context, bail, ensure};
 use fleet_shared::SecretData;
 use itertools::Itertools;
-use nix_eval::{NativeFn, Value, nix_go, nix_go_json};
+use nix_eval::{NativeFn, Value, await_in_nix, nix_go, nix_go_json};
 use serde::Deserialize;
 use tracing::{info, warn};
 
 use crate::fleetdata::{
 	Expectations, FleetSecretData, FleetSecretDistribution, FleetSecretPart, GeneratorPart,
+	RegenerationConstraints, SecretOwner,
 };
 use crate::host::{Config, ConfigHost};
-use crate::secret::{RegenerationReason, secret_needs_regeneration};
 use anyhow::{Result, anyhow};
-
-#[derive(thiserror::Error, Debug)]
-enum Error {}
 
 pub static PRIMOPS_DATA: OnceLock<Config> = OnceLock::new();
 
@@ -28,7 +25,6 @@
 }
 
 pub fn get_pkgs_and_generators(host_on: &ConfigHost, recipients: Vec<String>) -> Result<Value> {
-	info!("get pkgs");
 	let pkgs = host_on.pkgs()?;
 	let default_mk_secret_generators = nix_go!(pkgs.mkSecretGenerators);
 	let generators = nix_go!(default_mk_secret_generators(Obj { recipients }));
@@ -57,6 +53,31 @@
 	Ok(default_generator_drv)
 }
 
+fn secret_to_parts(
+	secret_name: &str,
+	secret: &BTreeMap<String, FleetSecretPart>,
+	expected: &BTreeMap<String, GeneratorPart>,
+) -> Value {
+	let mut out = HashMap::new();
+	for (part_name, part) in secret {
+		if !expected.contains_key(part_name) {
+			warn!(
+				"secret {secret_name} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix"
+			);
+			continue;
+		};
+		out.insert(
+			part_name.as_str(),
+			Value::new_attrs(HashMap::from_iter([(
+				"raw",
+				Value::new_str(&part.raw.to_string()),
+			)])),
+		);
+	}
+
+	Value::new_attrs(out)
+}
+
 pub async fn generate(
 	config: &Config,
 	expectations: Expectations,
@@ -76,9 +97,12 @@
 			} else {
 				config.local_host()
 			};
-			let pkgs_and_generators =
-				get_pkgs_and_generators(&host_on, expectations.owners.iter().cloned().collect())
-					.context("failed to get pkgs for target host")?;
+			let mut recipients = Vec::new();
+			for owner in &expectations.owners {
+				recipients.push(config.key(owner).await?);
+			}
+			let pkgs_and_generators = get_pkgs_and_generators(&host_on, recipients)
+				.context("failed to get pkgs for target host")?;
 			let generator = call_package(config, &pkgs_and_generators, generator)
 				.context("failed to evaluate generator for target host")?;
 
@@ -147,15 +171,8 @@
 				generation_data: expectations.generation_data.clone(),
 			};
 
-			let new_data = FleetSecretDistribution {
-				secret: new_data,
-				owners: expectations.owners.clone(),
-				_deprecated_managed: true,
-			};
-
-			if let Some(reason) = secret_needs_regeneration(&new_data, &expectations) {
-				bail!("newly generated secret needs to be regenerated: {reason}")
-			}
+			let new_data =
+				FleetSecretDistribution::new(expectations.owners.clone(), new_data, config.now);
 
 			Ok(new_data)
 		}
@@ -166,18 +183,41 @@
 }
 
 pub fn init_primops() {
-	info!("initializing primops");
 	NativeFn::new(
+		c"__fleetEnsureHostSecrets",
+		c"Ensure no extra secrets are stored for the host, pruning unknown",
+		[c"host", c"expectedNonshared", c"expectedShared", c"rest"],
+		|_es, [host, expected_nonshared, expected_shared, rest]| {
+			let host = SecretOwner::host(host.to_string()?);
+			let expected_nonshared: BTreeSet<String> = expected_nonshared.as_json()?;
+			let expected_shared: BTreeSet<String> = expected_shared.as_json()?;
+
+			let mut expected = expected_nonshared;
+			expected.extend(expected_shared);
+
+			let config = PRIMOPS_DATA
+				.get()
+				.expect("primops data should be set on init");
+
+			config
+				.data
+				.secrets
+				.write()
+				.expect("no poisoning")
+				.prune_host(&host, expected);
+
+			Ok(rest.clone())
+		},
+	)
+	.register();
+	NativeFn::new(
 		c"__fleetEnsureHostSecret",
 		c"Ensure secret existence for a host, regenerating it in case of some mismatch",
 		[c"host", c"secret", c"generator"],
 		|es, [host, secret, generator]| {
-			info!("get host");
-			let host = host.to_string()?;
-			info!("get secret");
+			let host = SecretOwner::host(&host.to_string()?);
 			let secret = secret.to_string()?;
 
-			info!("get config");
 			let config = PRIMOPS_DATA
 				.get()
 				.expect("primops data should be set on init");
@@ -193,50 +233,101 @@
 
 				ensure!(expected_owners.contains(&host), "secret {secret} does not define {host} as expected owner");
 
-				(true, shared_def.generator()?, expected_owners)
+				(Some(shared_def.clone()), shared_def.generator()?, expected_owners)
 			} else {
 				if shared_def.is_some() {
 					bail!("hosts can only have their own generators for non-shared secrets, either set host secret generator to \"shared\", or remove shared secret generator at fleetConfiguration.secrets.{secret}.generator")
 				}
 
-				(false, generator.clone(), BTreeSet::from_iter([host.clone()]))
+				(None, generator.clone(), BTreeSet::from_iter([host.clone()]))
 			};
 
-			let default_generator_drv = get_default_generator_drv(config, &generator).context("failed to evaluate default generator")?;
-			let expectations = Expectations {
+			let default_generator_drv = get_default_generator_drv(config, &generator)?;
+			let mut expectations = Expectations {
 				parts: nix_go_json!(default_generator_drv.parts),
 				generation_data: nix_go_json!(default_generator_drv.generationData),
-				owners: expected_owners,
+				owners: expected_owners.clone(),
+			};
+			let constraints = if let Some(shared) = &shared{
+				RegenerationConstraints {
+					allow_different: nix_go_json!(default_generator_drv.allowDifferent) && shared.allow_different()?,
+					regenerate_on_owner_added: shared.regenerate_on_owner_added()?,
+					regenerate_on_owner_removed: shared.regenerate_on_owner_added()?,
+				}
+			} else {
+				RegenerationConstraints::host_personal()
 			};
 
-			let reason: RegenerationReason = 'regenerate: {
-				let Some(existing) = config
-					.host_secret(&host, &secret) else {
-					break 'regenerate RegenerationReason::Missing;
+			let mut secrets = config.data.secrets.write().expect("no poisoning");
+			let dists = secrets.get_or_create(&secret);
+
+				if shared.is_some() {
+					dists.prune_shared(&expected_owners, !constraints.allow_different, &expectations.parts, &expectations.generation_data, constraints.regenerate_on_owner_removed, constraints.regenerate_on_owner_added, &config.prefer_identities, config.now);
+				} else {
+					dists.prune_host(host.clone(), &expectations.parts, &expectations.generation_data, config.now);
+				};
+
+				if let Some(dist) = dists.get(&host) {
+					return Ok(secret_to_parts(&secret, &dist.secret.parts, &expectations.parts));
 				};
-				if let Some(reason) = secret_needs_regeneration(&existing, &expectations) {
-					break 'regenerate reason;
+
+				let mut reencrypt_targets = expectations.owners.clone();
+				for dist in dists.distributions() {
+					for own in dist.owners() {
+						reencrypt_targets.remove(own);
+					}
 				}
+				if !constraints.regenerate_on_owner_added {
+					if let Some(unpruned) = dists.try_unprune(host.clone()) {
+						return Ok(secret_to_parts(&secret, &unpruned.secret.parts, &expectations.parts));
+					} else if let Some(best) = dists.best_distribution_for_reencryption(&config.prefer_identities) {
+						let new_owners = reencrypt_targets.clone();
+						let mut reencrypt_targets = reencrypt_targets;
+						reencrypt_targets.extend(best.owners().cloned());
 
-				let mut parts = expectations.parts.clone();
+						let mut preferred = best.owners().collect_vec();
+						preferred.sort_by_key(|v| !config.prefer_identities.contains(*v));
+
+						warn!("reencrypting secret {secret} as it is missing for host {host}");
 
-				let mut out = HashMap::new();
-				for (part_name, part) in &existing.secret.parts {
-					let Some(definition) = parts.remove(part_name) else {
-						warn!("secret {secret} part {part_name} is stored, but not defined in nixos config, it will not be passed to nix");
-						continue;
+						for owner in preferred {
+							if let Some(hostname) = owner.as_host() && let Ok(host) = config.host(hostname) {
+								let best = best.clone();
+								let reencrypt_targets = reencrypt_targets.clone();
+								let reencrypted = match await_in_nix(async move {
+										host.reencrypt_distribution(&best, reencrypt_targets.clone(), config.now).await
+								}) {
+									Ok(r) => r,
+									Err(e) => {
+										warn!("reencryption failed on {hostname}: {e:?}");
+										continue;
+									}
+								};
+								dists.extend(reencrypted.clone(), format!("secret was reencrypted to extend with new owners: {new_owners:?}"));
+								return Ok(secret_to_parts(&secret, &reencrypted.secret.parts, &expectations.parts));
+							};
+						}
+						warn!("failed to reencrypt using any host")
 					};
-					assert!(definition.encrypted != part.raw.encrypted, "encryption status is checked by secret_needs_regeneration");
-					out.insert(part_name.as_str(), Value::new_attrs(HashMap::from_iter([("raw", Value::new_str(&part.raw.to_string()))])));
-				}
-				assert!(parts.is_empty(), "secret part is missing, secret_needs_regeneration should check that");
+				};
 
-				return Ok(Value::new_attrs(out))
-			};
+			if constraints.allow_different {
+				for dist in dists.distributions() {
+					for own in dist.owners() {
+						expectations.owners.remove(own);
+					}
+				}
+			}
+			info!("secret {secret} is being generated for {:?}", expectations.owners);
 
-			todo!()
+			let expectations_ = expectations.clone();
+			let generated = await_in_nix(async move {
+				generate(config, expectations_, &generator, &default_generator_drv).await
+			})?;
 
+			dists.extend(generated.clone(), format!("secret was generated"));
 
+			return Ok(secret_to_parts(&secret, &generated.secret.parts, &expectations.parts));
 		},
 	)
 	.register();
deletedcrates/fleet-base/src/secret.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/secret.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-use std::collections::{BTreeMap, BTreeSet};
-
-use chrono::{DateTime, Utc};
-
-use crate::fleetdata::{Expectations, FleetSecretData, FleetSecretDistribution, GeneratorPart};
-
-#[derive(thiserror::Error, Debug)]
-pub enum RegenerationReason {
-	#[error("owners added: {0:?}")]
-	OwnersAdded(BTreeSet<String>),
-	#[error("owners added: {0:?}")]
-	OwnersRemoved(BTreeSet<String>),
-	#[error("unexpected generation data, expected: {expected:?}, found: {found:?}")]
-	GenerationData {
-		expected: serde_json::Value,
-		found: serde_json::Value,
-	},
-	#[error("unexpected part list, expected: {expected:?}, found: {found:?}")]
-	PartList {
-		expected: BTreeSet<String>,
-		found: BTreeSet<String>,
-	},
-	#[error("part {0} is expected to be encrypted")]
-	ExpectedPrivate(String),
-	#[error("part {0} is not expected to be encrypted")]
-	ExpectedPublic(String),
-	#[error("secret is expired at {0}")]
-	Expired(DateTime<Utc>),
-
-	#[error("secret is not generated for this host")]
-	Missing,
-}
-
-pub fn secret_needs_regeneration(
-	secret: &FleetSecretDistribution,
-	expectations: &Expectations,
-) -> Option<RegenerationReason> {
-	let added: BTreeSet<String> = expectations
-		.owners
-		.difference(&secret.owners)
-		.cloned()
-		.collect();
-	if !added.is_empty() {
-		return Some(RegenerationReason::OwnersAdded(added));
-	}
-
-	let removed: BTreeSet<String> = secret
-		.owners
-		.difference(&expectations.owners)
-		.cloned()
-		.collect();
-	if !removed.is_empty() {
-		return Some(RegenerationReason::OwnersRemoved(removed));
-	}
-
-	if secret.secret.generation_data != expectations.generation_data {
-		return Some(RegenerationReason::GenerationData {
-			expected: expectations.generation_data.clone(),
-			found: secret.secret.generation_data.clone(),
-		});
-	}
-
-	let expected: BTreeSet<String> = expectations.parts.keys().cloned().collect();
-	let found: BTreeSet<String> = secret.secret.parts.keys().cloned().collect();
-
-	if found != expected {
-		return Some(RegenerationReason::PartList { expected, found });
-	}
-
-	for (name, value) in secret.secret.parts.iter() {
-		let expectation = expectations
-			.parts
-			.get(name)
-			.expect("found == expected checked");
-		if value.raw.encrypted {
-			if !expectation.encrypted {
-				return Some(RegenerationReason::ExpectedPrivate(name.clone()));
-			}
-		} else if expectation.encrypted {
-			return Some(RegenerationReason::ExpectedPublic(name.clone()));
-		}
-	}
-
-	if let Some(expiration) = secret.secret.expires_at {
-		// TODO: Leeway?
-		if expiration < Utc::now() {
-			return Some(RegenerationReason::Expired(expiration));
-		}
-	}
-
-	None
-}
modifiedcrates/fleet-shared/Cargo.tomldiffbeforeafterboth
--- a/crates/fleet-shared/Cargo.toml
+++ b/crates/fleet-shared/Cargo.toml
@@ -8,4 +8,3 @@
 base64 = "0.22.1"
 serde = "1.0.219"
 unicode_categories = "0.1.1"
-z85 = "3.0.6"
modifiedcrates/fleet-shared/src/encoding.rsdiffbeforeafterboth
--- a/crates/fleet-shared/src/encoding.rs
+++ b/crates/fleet-shared/src/encoding.rs
@@ -1,5 +1,4 @@
 use std::{
-	collections::BTreeMap,
 	fmt::{self, Display},
 	str::FromStr,
 };
@@ -15,7 +14,6 @@
 }
 
 const BASE64_ENCODED_PREFIX: &str = "<BASE64-ENCODED>\n";
-const Z85_ENCODED_PREFIX: &str = "<Z85-ENCODED>\n";
 // Multiline text in Nix can only end with \n, which is not cool for actual single-line strings.
 const PLAINTEXT_NEWLINE_PREFIX: &str = "<PLAINTEXT-NL>\n";
 const PLAINTEXT_PREFIX: &str = "<PLAINTEXT>";
@@ -54,18 +52,12 @@
 			STANDARD_NO_PAD
 				.decode(unprefixed.replace(['\n', '\t', ' '], ""))
 				.map_err(|e| format!("base64-encoded failed: {e}"))?
-		} else if let Some(unprefixed) = string.strip_prefix(Z85_ENCODED_PREFIX) {
-			z85::decode(unprefixed.replace(['\n', '\t', ' '], ""))
-				.map_err(|e| format!("z85-encoded failed: {e}"))?
 		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_NEWLINE_PREFIX) {
 			unprefixed.as_bytes().to_owned()
 		} else if let Some(unprefixed) = string.strip_prefix(PLAINTEXT_PREFIX) {
 			unprefixed.as_bytes().to_owned()
 		} else {
-			let secret_prefix = format!("{SECRET_PREFIX}{Z85_ENCODED_PREFIX}");
-			return Err(format!(
-				"unknown secret encoding. If you're migrating from old version of fleet, prefix public secret fields with {PLAINTEXT_PREFIX:?}, and encrypted data with {secret_prefix:?}: {string}"
-			));
+			return Err(format!("unknown secret encoding"));
 		};
 		Ok(Self { data, encrypted })
 	}
modifiedcrates/nix-eval/Cargo.tomldiffbeforeafterboth
--- a/crates/nix-eval/Cargo.toml
+++ b/crates/nix-eval/Cargo.toml
@@ -18,6 +18,7 @@
 test-log = { version = "0.2.18", features = ["trace"] }
 tracing-indicatif = { version = "0.3.13", optional = true }
 vte = { version = "0.15.0", features = ["ansi"] }
+tokio.workspace = true
 
 [build-dependencies]
 bindgen = "0.72.0"
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -2,7 +2,7 @@
 use std::cell::RefCell;
 use std::ffi::{CStr, CString, c_char, c_int, c_uint, c_void};
 use std::ptr::{null, null_mut};
-use std::sync::LazyLock;
+use std::sync::{Arc, LazyLock, OnceLock};
 use std::{array, fmt, slice};
 use std::{collections::HashMap, path::PathBuf};
 
@@ -10,9 +10,10 @@
 use itertools::Itertools;
 use serde::Serialize;
 use serde::de::DeserializeOwned;
+use std::mem::transmute;
 
 pub use anyhow::Result;
-use tracing::{info, instrument, warn};
+use tracing::{Instrument, info, instrument, warn};
 
 use self::logging::{ErrorInfoBuilder, nix_logging_cxx};
 use self::nix_cxx::set_fetcher_setting;
@@ -172,9 +173,16 @@
 #[repr(transparent)]
 pub struct NixContext(*mut c_context);
 impl NixContext {
-	pub fn set_err(&mut self, err: NixErrorKind, msg: &CStr) {
+	pub fn set_err_raw(&mut self, err: NixErrorKind, msg: &CStr) {
 		unsafe { set_err_msg(self.0, err as c_int, msg.as_ptr()) };
 	}
+	pub fn set_err(&mut self, err: anyhow::Error) {
+		let mut fmt = format!("{err:?}").replace("\0", "\\0");
+		self.set_err_raw(
+			NixErrorKind::Generic,
+			&CString::new(fmt).expect("NUL bytes were just replaced"),
+		);
+	}
 	pub fn new() -> Self {
 		let ctx = unsafe { c_context_create() };
 		Self(ctx)
@@ -931,6 +939,8 @@
 	}
 }
 
+static TOKIO_FOR_NIX: OnceLock<Arc<tokio::runtime::Runtime>> = OnceLock::new();
+
 pub fn init_libraries() {
 	unsafe { GC_allow_register_threads() };
 
@@ -945,37 +955,33 @@
 	nix_logging_cxx::apply_tracing_logger();
 }
 
+pub fn init_tokio_for_nix(tokio: Arc<tokio::runtime::Runtime>) {
+	TOKIO_FOR_NIX
+		.set(tokio)
+		.expect("tokio for nix should only be initialized once");
+}
+
+pub fn await_in_nix<F: Send + 'static>(f: impl Future<Output = F> + Send + 'static) -> F {
+	// It should be possible to do Handle::current(), but some of the planned features don't work well with that
+	let runtime = TOKIO_FOR_NIX
+		.get()
+		.expect("init_tokio_for_nix was not called");
+	std::thread::spawn(move || runtime.block_on(f)).join().expect("await_in_nix inner thread panicked")
+}
+
 unsafe extern "C" fn nix_primop_closure_adapter<const N: usize>(
 	user_data: *mut c_void,
-	context: *mut c_context,
+	mut context: *mut c_context,
 	state: *mut nix_raw::EvalState,
 	args: *mut *mut value,
 	ret: *mut value,
 ) {
 	let user_closure: &UserClosure<N> = unsafe { &*user_data.cast_const().cast() };
-	let mut e = None;
 	let args: [&Value; N] = array::from_fn(|i| {
 		let v: &mut Value = unsafe { &mut *args.add(i).cast() };
-
-		info!("forcing arg");
-		if matches!(v.type_of(), NixType::Thunk)
-			&& let Err(err) = v.force(state)
-		{
-			e = Some(err);
-		};
 		v as &Value
 	});
-	info!("args forced");
-	let ctx: &mut NixContext = unsafe { &mut *context.cast() };
-
-	if let Some(e) = e {
-		warn!("set err = {e}");
-		unsafe { init_int(context, ret, 0) };
-		return ctx.set_err(
-			NixErrorKind::Unknown,
-			&CString::new(e.to_string()).expect("forcing argument value failed"),
-		);
-	}
+	let ctx: &mut NixContext = unsafe { transmute(&mut context) };
 
 	let state: &EvalState = unsafe { std::mem::transmute(&state) };
 
@@ -984,12 +990,7 @@
 			unsafe { copy_value(context, ret, v.0) };
 		}
 		Err(e) => {
-			unsafe { init_int(context, ret, 0) };
-			warn!("set err = {e:#?}");
-			ctx.set_err(
-				NixErrorKind::Unknown,
-				&CString::new(e.to_string()).expect("error should not contain internal nuls"),
-			);
+			ctx.set_err(e);
 		}
 	}
 }
modifiedcrates/nix-eval/src/macros.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/macros.rs
+++ b/crates/nix-eval/src/macros.rs
@@ -68,13 +68,13 @@
 #[macro_export]
 macro_rules! nix_go {
 	(@o($o:expr, $path:expr) . $var:ident $($tt:tt)*) => {{
-		nix_go!(@o($o.get_field(stringify!($var)).context(concat!("getting nested ", $path))?, $path) $($tt)*)
+		nix_go!(@o(tokio::task::block_in_place(|| $o.get_field(stringify!($var))).context(concat!("getting nested ", $path))?, $path) $($tt)*)
 	}};
 	(@o($o:expr, $path:expr) [ $v:expr ] $($tt:tt)*) => {{
-		nix_go!(@o($o.get_field($v).context(concat!("getting nested ", $path))?, $path) $($tt)*)
+		nix_go!(@o(tokio::task::block_in_place(|| $o.get_field($v)).context(concat!("getting nested ", $path))?, $path) $($tt)*)
 	}};
 	(@o($o:expr, $path:expr) ($($var:tt)*) $($tt:tt)*) => {
-		nix_go!(@o($o.call($crate::nix_expr_inner!($($var)+)).context(concat!("getting nested ", $path))?, $path) $($tt)*)
+		nix_go!(@o(tokio::task::block_in_place(|| $o.call($crate::nix_expr_inner!($($var)+))).context(concat!("getting nested ", $path))?, $path) $($tt)*)
 	};
 	(@o($o:expr, $path:expr)) => {$o};
 	($field:ident $($tt:tt)+) => {{
@@ -87,6 +87,6 @@
 #[macro_export]
 macro_rules! nix_go_json {
 	($($tt:tt)*) => {{
-		$crate::nix_go!($($tt)*).as_json()?
+		tokio::task::block_in_place(|| $crate::nix_go!($($tt)*).as_json())?
 	}};
 }
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -2,10 +2,10 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1767461147,
+        "lastModified": 1768700043,
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "7d59256814085fd9666a2ae3e774dc5ee216b630",
+        "rev": "935de8bd6838d940988bb065be2a2034259327b9",
         "type": "github"
       },
       "original": {
@@ -37,10 +37,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1767609335,
+        "lastModified": 1768135262,
         "owner": "hercules-ci",
         "repo": "flake-parts",
-        "rev": "250481aafeb741edfe23d29195671c19b36b6dca",
+        "rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
         "type": "github"
       },
       "original": {
@@ -111,10 +111,10 @@
         "nixpkgs-regression": "nixpkgs-regression"
       },
       "locked": {
-        "lastModified": 1767670640,
+        "lastModified": 1768702010,
         "owner": "deltarocks",
         "repo": "nix",
-        "rev": "2181cd07134c9049bd77b7f48c3b1ea8647267de",
+        "rev": "b05b52670b9c7affff5b9be3edb539a1603c39e6",
         "type": "github"
       },
       "original": {
@@ -126,10 +126,10 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1767657734,
+        "lastModified": 1768697925,
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "d4ccebf51ee4dbeb9df364dce1fe9848635c1258",
+        "rev": "665062f7df2c7db8fdbbec4f1b730091143828a3",
         "type": "github"
       },
       "original": {
@@ -190,10 +190,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1767667566,
+        "lastModified": 1768617670,
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "056ce5b125ab32ffe78c7d3e394d9da44733c95e",
+        "rev": "56d0fbdd732f3686e8414b857cf885038fc17d57",
         "type": "github"
       },
       "original": {
@@ -223,10 +223,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1767468822,
+        "lastModified": 1768158989,
         "owner": "numtide",
         "repo": "treefmt-nix",
-        "rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc",
+        "rev": "e96d59dff5c0d7fddb9d113ba108f03c3ef99eca",
         "type": "github"
       },
       "original": {
modifiedmodules/nixos.nixdiffbeforeafterboth
--- a/modules/nixos.nix
+++ b/modules/nixos.nix
@@ -10,7 +10,7 @@
 let
   inherit (lib.attrsets) mapAttrs;
   inherit (lib.options) mkOption;
-  inherit (lib.types) deferredModule unspecified;
+  inherit (lib.types) deferredModule unspecified uniq str;
   inherit (lib.strings) escapeNixIdentifier;
   inherit (fleetLib.options) mkHostsOption;
 
@@ -24,9 +24,18 @@
       '';
       type = deferredModule;
     };
-    hosts = mkHostsOption (hostArgs: {
+    hosts = mkHostsOption (hostArgs: let
+      hostName = hostArgs.config._module.args.name;
+    in {
       inherit _file;
       options = {
+        name = mkOption {
+          description = ''
+            Host name (alias)
+          '';
+          type = uniq str;
+          default = hostName;
+        };
         nixos = mkOption {
           description = ''
             Nixos configuration for the current host.
@@ -42,7 +51,7 @@
               prefix = [
                 "fleetConfiguration"
                 "hosts"
-                hostArgs.config._module.args.name
+                hostName
                 "nixos"
               ];
               modules = (import "${modulesPath}/module-list.nix") ++ [
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -15,7 +15,12 @@
   inherit (lib.stringsWithDeps) stringAfter;
   inherit (lib.options) mkOption literalExpression;
   inherit (lib.lists) optional elem;
-  inherit (lib.attrsets) mapAttrs mapAttrsToList;
+  inherit (lib.attrsets)
+    mapAttrs
+    mapAttrsToList
+    filterAttrs
+    attrNames
+    ;
   inherit (lib.modules) mkIf;
   inherit (lib.types)
     submodule
@@ -25,9 +30,9 @@
     uniq
     functionTo
     package
-    bool
     enum
     either
+    listOf
     ;
   inherit (fleetLib.strings) decodeRawSecret;
 
@@ -132,10 +137,33 @@
 in
 {
   options = {
+    _providedSharedSecrets = mkOption {
+      description = ''
+        List of shared secrets, for which the current host was specified as `expectedOwners`
+      '';
+      type = listOf str;
+      default = [];
+      internal = true;
+    };
     secrets = mkOption {
       type = attrsOf secretType;
       default = { };
-      apply = mapAttrs (_: secret: secret.parts // { definition = secret; });
+      apply =
+        secrets:
+        mapAttrs (_: secret: secret.parts // { definition = secret; })
+
+          (
+            let
+              hostName = host.name;
+              expectedNonshared = attrNames (filterAttrs (_: def: def.generator != "shared") secrets);
+              expectedShared = config._providedSharedSecrets;
+            in
+            builtins.deepSeq [
+              hostName
+              expectedNonshared
+              expectedShared
+            ] (builtins.fleetEnsureHostSecrets hostName expectedNonshared expectedShared secrets)
+          );
       description = "Host-local secrets";
     };
     system.secretsData = mkOption {
@@ -163,7 +191,7 @@
           (secret.definition.generator == "shared") == hasSharedDefinition
           && (
             hasSharedDefinition
-            -> (elem host._module.args.name fleetConfiguration.secrets.${name}.expectedOwners)
+            -> (elem host.name fleetConfiguration.secrets.${name}.expectedOwners)
           );
         message =
           if hasSharedDefinition then
modifiedmodules/secrets.nixdiffbeforeafterboth
--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -1,9 +1,10 @@
 {
   lib,
+  config,
   ...
 }:
 let
-  inherit (lib.options) mkOption literalExpression;
+  inherit (lib.options) mkOption;
   inherit (lib.types)
     nullOr
     listOf
@@ -16,6 +17,8 @@
     uniq
     ;
   inherit (lib.strings) concatStringsSep;
+  inherit (lib.lists) elem filter;
+  inherit (lib.attrsets) attrNames;
 
   sharedSecret =
     { config, ... }:
@@ -30,28 +33,33 @@
         regenerateOnOwnerAdded = mkOption {
           type = bool;
           description = ''
-            Controls whether the secret must be regenerated when new owners are added.
+            Whether the secret prefers to be rotated when new owners are added.
 
-            Set to true when the secret contains owner-specific references (e.g., X.509 Subject Alternative Names).
-            When true, adding a new owner will trigger secret regeneration instead of simple re-encryption.
+            Note that this is only a security measure, if the secret needs to be regenerated due to e.g X.509 SANs
+            changes - then you most likely want to use generationData for that instead.
           '';
+          default = false;
         };
         regenerateOnOwnerRemoved = mkOption {
-          default = config.regenerateOnOwnerAdded;
-          defaultText = literalExpression "regenerateOnOwnerAdded";
           type = bool;
           description = ''
-            Determines secret behavior when owners are removed from the configuration.
-
-            Typically mirrors regenerateOnOwnerAdded. Override cautiously.
-            Set to false if host permissions are revoked through alternative mechanisms like firewall rules.
+            Whether the secret prefers to be rotated when the owners are removed, so the encrypted data
+            stored in fleet state can't be decrypted by those. Note that the secrets are still present in encrypted
+            form on those hosts until gc happens.
           '';
+          default = false;
         };
         allowDifferent = mkOption {
           type = bool;
           description = ''
-            When adding owner, do not update secret value for other owners, instead creating a new distribution
+            When adding owner, do not update secret value for other owners, instead creating a new distribution.
+
+            Defaults to true, since all secrets might differ on hosts on some point of deployment process.
+
+            Secret generator might also have opinion on this, like it makes little sense for askPass/synchronizing
+            generators to keep old data.
           '';
+          default = true;
         };
         generator = mkOption {
           type = uniq (nullOr (functionTo package));
@@ -75,6 +83,9 @@
     };
   };
   config = {
+    nixos = {host, ...}: {
+      _providedSharedSecrets = filter (name: elem host.name config.secrets.${name}.expectedOwners) (attrNames config.secrets);
+    };
     nixpkgs.overlays = [
       (final: prev: {
         mkSecretGenerators =
@@ -90,6 +101,7 @@
                 # (Some secrets-encryption-in-git/managed PKI solution is expected)
                 impureOn ? null,
                 generationData ? null,
+                allowDifferent ? true,
                 parts,
               }:
               (prev.writeShellScript "impureGenerator.sh" ''
@@ -118,7 +130,12 @@
               '').overrideAttrs
                 (old: {
                   passthru = {
-                    inherit impureOn parts generationData;
+                    inherit
+                      impureOn
+                      parts
+                      generationData
+                      allowDifferent
+                      ;
                     generatorKind = "impure";
                   };
                 });