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
before · crates/fleet-base/src/fleetdata.rs
1use std::{2	collections::{3		BTreeMap, BTreeSet,4		btree_map::{self, Entry},5	},6	io::{self, Cursor},7	ops::Deref,8};910use age::Recipient;11use chrono::{DateTime, Utc};12use fleet_shared::SecretData;13use rand::{14	distr::{Alphanumeric, SampleString as _},15	rng,16};17use serde::{18	Deserialize, Serialize,19	de::{self, Error},20};21use serde_json::Value;22use tracing::info;2324#[derive(Serialize, Deserialize, Default)]25#[serde(rename_all = "camelCase")]26pub struct HostData {27	#[serde(default)]28	#[serde(skip_serializing_if = "String::is_empty")]29	pub encryption_key: String,30}3132const VERSION: &str = "0.1.0";33pub struct FleetDataVersion;34impl Serialize for FleetDataVersion {35	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>36	where37		S: serde::Serializer,38	{39		VERSION.serialize(serializer)40	}41}42impl<'de> Deserialize<'de> for FleetDataVersion {43	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>44	where45		D: serde::Deserializer<'de>,46	{47		let version = String::deserialize(deserializer)?;48		if version != VERSION {49			return Err(D::Error::custom(format!(50				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"51			)));52		}53		Ok(Self)54	}55}5657fn generate_gc_prefix() -> String {58	let id = Alphanumeric.sample_string(&mut rng(), 8);59	format!("fleet-gc-{id}")60}6162#[derive(Serialize, Deserialize)]63#[serde(rename_all = "camelCase")]64pub struct ManagerKey {65	pub name: String,66	pub key: String,67}6869#[derive(Serialize, Deserialize)]70#[serde(rename_all = "camelCase")]71pub struct FleetData {72	pub version: FleetDataVersion,73	#[serde(default = "generate_gc_prefix")]74	pub gc_root_prefix: String,7576	#[serde(default, skip_serializing_if = "Vec::is_empty")]77	pub manager_keys: Vec<ManagerKey>,7879	#[serde(default)]80	pub hosts: BTreeMap<String, HostData>,8182	#[serde(default, alias = "shared_secrets")]83	pub secrets: FleetSecrets,8485	// extra_name => anything86	#[serde(default)]87	#[serde(skip_serializing_if = "BTreeMap::is_empty")]88	pub extra: BTreeMap<String, Value>,8990	#[serde(default)]91	#[serde(skip_serializing_if = "BTreeMap::is_empty")]92	host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,93}94impl FleetData {95	pub fn from_str(s: &str) -> anyhow::Result<Self> {96		let mut data: Self = nixlike::parse_str(s)?;97		if !data.host_secrets.is_empty() {98			info!("migrating host secrets into shared secrets structure");99			data.secrets100				.merge_from_hosts(std::mem::take(&mut data.host_secrets));101		}102		Ok(data)103	}104}105106/// Returns None if recipients.is_empty()107pub fn encrypt_secret_data<'r>(108	recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,109	data: Vec<u8>,110) -> Option<SecretData> {111	let mut encrypted = vec![];112	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))113		.ok()?114		.wrap_output(&mut encrypted)115		.expect("in memory write");116	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");117	encryptor.finish().expect("in memory flush");118	Some(SecretData {119		data: encrypted,120		encrypted: true,121	})122}123124#[derive(Serialize, Deserialize, Clone, Debug)]125pub struct FleetSecretPart {126	pub raw: SecretData,127}128129#[derive(Serialize, Deserialize, Clone, Debug)]130#[serde(rename_all = "camelCase")]131#[must_use]132pub struct FleetSecretData {133	#[serde(default = "Utc::now")]134	pub created_at: DateTime<Utc>,135	#[serde(default)]136	#[serde(skip_serializing_if = "Option::is_none", alias = "expire_at")]137	pub expires_at: Option<DateTime<Utc>>,138139	#[serde(flatten)]140	pub parts: BTreeMap<String, FleetSecretPart>,141142	#[serde(default)]143	#[serde(skip_serializing_if = "Value::is_null")]144	pub generation_data: Value,145}146147#[derive(Serialize, Deserialize, Clone, Debug)]148#[serde(rename_all = "camelCase")]149#[must_use]150pub struct FleetSecretDistribution {151	#[serde(default)]152	pub owners: BTreeSet<String>,153	#[serde(flatten)]154	pub secret: FleetSecretData,155156	#[serde(default, skip_serializing, alias = "managed")]157	pub _deprecated_managed: bool,158}159160#[derive(Clone)]161#[must_use]162pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);163164impl Deref for FleetSecretDistributions {165	type Target = [FleetSecretDistribution];166167	fn deref(&self) -> &Self::Target {168		self.0.as_slice()169	}170}171172impl FleetSecretDistributions {173	pub fn owners(&self) -> impl Iterator<Item = &String> {174		self.0.iter().flat_map(|v| v.owners.iter())175	}176	#[allow(177		clippy::len_without_is_empty,178		reason = "should not be empty for a long time"179	)]180	pub fn len(&self) -> usize {181		self.0.len()182	}183184	pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {185		self.0.iter().find(|d| d.owners.contains(owner))186	}187	fn entry(&mut self, owner: String) -> DistEntry<'_> {188		let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {189			return DistEntry::Vacant(VacantDistEntry {190				distributions: self,191				owner,192			});193		};194		DistEntry::Occupied(OccupiedDistEntry {195			distributions: self,196			idx,197			owner,198		})199	}200	fn extend(&mut self, dist: FleetSecretDistribution) {201		for owner in &dist.owners {202			self.entry(owner.to_owned()).remove();203		}204		self.0.push(dist);205	}206	pub fn contains(&self, owner: &str) -> bool {207		self.0.iter().any(|d| d.owners.contains(owner))208	}209}210211struct OccupiedDistEntry<'d> {212	distributions: &'d mut FleetSecretDistributions,213	idx: usize,214	owner: String,215}216impl<'d> OccupiedDistEntry<'d> {217	fn remove(self) -> VacantDistEntry<'d> {218		let dist = &mut self.distributions.0[self.idx];219		assert!(220			dist.owners.remove(&self.owner),221			"entry exists, as we have its reference"222		);223		if dist.owners.is_empty() {224			self.distributions.0.remove(self.idx);225		}226		VacantDistEntry {227			distributions: self.distributions,228			owner: self.owner,229		}230	}231	fn set(self, secret: FleetSecretData) -> Self {232		self.remove().set(secret)233	}234}235struct VacantDistEntry<'d> {236	distributions: &'d mut FleetSecretDistributions,237	owner: String,238}239impl<'d> VacantDistEntry<'d> {240	fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {241		let Self {242			distributions,243			owner,244		} = self;245		let idx = distributions.0.len();246		distributions.0.push(FleetSecretDistribution {247			owners: BTreeSet::from_iter([owner.clone()]),248			secret,249250			_deprecated_managed: true,251		});252		OccupiedDistEntry {253			distributions,254			owner,255			idx,256		}257	}258}259260enum DistEntry<'d> {261	Vacant(VacantDistEntry<'d>),262	Occupied(OccupiedDistEntry<'d>),263}264impl DistEntry<'_> {265	fn remove(self) -> Self {266		match self {267			DistEntry::Vacant(_) => self,268			DistEntry::Occupied(o) => Self::Vacant(o.remove()),269		}270	}271	fn set(self, secret: FleetSecretData) -> Self {272		Self::Occupied(match self {273			DistEntry::Vacant(e) => e.set(secret),274			DistEntry::Occupied(e) => e.set(secret),275		})276	}277}278279impl Serialize for FleetSecretDistributions {280	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>281	where282		S: serde::Serializer,283	{284		let mut found_hosts = BTreeSet::new();285		for ele in self.0.iter() {286			if ele.owners.is_empty() {287				panic!("consistency: secret distribution has no defined owners");288			}289			for ele in ele.owners.iter() {290				if !found_hosts.insert(ele) {291					panic!(292						"consistency: secret distribution contains duplicate entry for the same host",293					);294				}295			}296		}297		match self.0.len() {298			0 => panic!("consistency: empty distributions"),299			1 => self.0[0].serialize(serializer),300			_ => self.0.serialize(serializer),301		}302	}303}304impl<'de> Deserialize<'de> for FleetSecretDistributions {305	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>306	where307		D: serde::Deserializer<'de>,308	{309		#[derive(Deserialize)]310		#[serde(untagged)]311		enum Distributions {312			One(FleetSecretDistribution),313			Many(Vec<FleetSecretDistribution>),314		}315		let d = Distributions::deserialize(deserializer)?;316		let ds = match d {317			Distributions::One(d) => vec![d],318			Distributions::Many(ds) => ds,319		};320		if ds.is_empty() {321			return Err(de::Error::custom("consistency: empty distributions"));322		}323		let mut found_hosts = BTreeSet::new();324		for ele in ds.iter() {325			if ele.owners.is_empty() {326				return Err(de::Error::custom(327					"consistency: secret distribution has no defined owners",328				));329			}330			for ele in ele.owners.iter() {331				if !found_hosts.insert(ele) {332					return Err(de::Error::custom(333						"consistency: secret distribution contains duplicate entry for the same host",334					));335				}336			}337		}338		Ok(Self(ds))339	}340}341342#[derive(Serialize, Deserialize, Default)]343pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);344345impl FleetSecrets {346	pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {347		self.0.keys()348	}349350	pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {351		self.0352			.iter()353			.filter(|(_, d)| d.contains(owner))354			.map(|(n, _)| n)355	}356357	pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {358		let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {359			return false;360		};361		let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {362			return false;363		};364365		dist.remove();366367		if dists.get().0.is_empty() {368			dists.remove();369		};370371		true372	}373	pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {374		let e = self375			.0376			.entry(secret.to_owned())377			.or_insert_with(|| FleetSecretDistributions(Default::default()));378		e.entry(owner.to_owned()).set(data);379	}380	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {381		match self.0.entry(secret) {382			Entry::Vacant(e) => {383				e.insert(FleetSecretDistributions(vec![data]));384			}385			Entry::Occupied(mut e) => {386				let dists = e.get_mut();387				dists.extend(data)388			}389		}390	}391	pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {392		let secret = self.0.get(secret)?;393		secret.get(owner)394	}395	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {396		self.0.get(secret)397	}398399	pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {400		let Some(secret) = self.0.get(secret) else {401			return false;402		};403		secret.contains(owner)404	}405	pub fn contains(&self, secret: &str) -> bool {406		self.0.contains_key(secret)407	}408	pub fn remove(&mut self, secret: &str) {409		self.0.remove(secret);410	}411412	fn merge_from_hosts(413		&mut self,414		host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,415	) {416		for (host, host_secrets) in host_secrets {417			for (secret_name, mut secret_data) in host_secrets {418				secret_data.owners.insert(host.clone());419				self.set_data(secret_name, secret_data);420			}421		}422	}423}424425#[derive(Debug)]426pub struct Expectations {427	pub owners: BTreeSet<String>,428	pub generation_data: serde_json::Value,429	pub parts: BTreeMap<String, GeneratorPart>,430}431#[derive(Deserialize, Debug, Clone)]432pub struct GeneratorPart {433	pub encrypted: bool,434}
after · crates/fleet-base/src/fleetdata.rs
1use std::{2	cmp::Ordering,3	collections::{4		BTreeMap, BTreeSet,5		btree_map::{self, Entry},6	},7	fmt,8	io::{self, Cursor},9	sync::RwLock,10};1112use age::Recipient;13use chrono::{DateTime, Utc};14use fleet_shared::SecretData;15use rand::{16	distr::{Alphanumeric, SampleString as _},17	rng,18};19use serde::{20	Deserialize, Serialize,21	de::{self, Error},22};23use serde_json::Value;24use tracing::info;2526#[derive(Serialize, Deserialize, Default)]27#[serde(rename_all = "camelCase")]28pub struct HostData {29	#[serde(default)]30	#[serde(skip_serializing_if = "String::is_empty")]31	pub encryption_key: String,32}3334const VERSION: &str = "0.1.0";35pub struct FleetDataVersion;36impl Serialize for FleetDataVersion {37	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>38	where39		S: serde::Serializer,40	{41		VERSION.serialize(serializer)42	}43}44impl<'de> Deserialize<'de> for FleetDataVersion {45	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>46	where47		D: serde::Deserializer<'de>,48	{49		let version = String::deserialize(deserializer)?;50		if version != VERSION {51			return Err(D::Error::custom(format!(52				"fleet.nix data version mismatch, expected {VERSION}, got {version}.\nFollow the docs for migration instruction"53			)));54		}55		Ok(Self)56	}57}5859fn generate_gc_prefix() -> String {60	let id = Alphanumeric.sample_string(&mut rng(), 8);61	format!("fleet-gc-{id}")62}6364#[derive(Serialize, Deserialize)]65#[serde(rename_all = "camelCase")]66pub struct ManagerKey {67	pub name: String,68	pub key: String,69}7071#[derive(Serialize, Deserialize)]72#[serde(rename_all = "camelCase")]73pub struct FleetData {74	pub version: FleetDataVersion,75	#[serde(default = "generate_gc_prefix")]76	pub gc_root_prefix: String,7778	#[serde(default, skip_serializing_if = "Vec::is_empty")]79	pub manager_keys: Vec<ManagerKey>,8081	#[serde(default)]82	pub hosts: RwLock<BTreeMap<String, HostData>>,8384	#[serde(default, alias = "shared_secrets")]85	pub secrets: RwLock<FleetSecrets>,8687	// extra_name => anything88	#[serde(default)]89	pub extra: RwLock<BTreeMap<String, Value>>,9091	#[serde(default)]92	#[serde(skip_serializing)]93	host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,94}95impl FleetData {96	pub fn from_str(s: &str) -> anyhow::Result<Self> {97		let mut data: Self = nixlike::parse_str(s)?;98		if !data.host_secrets.is_empty() {99			info!("migrating host secrets into shared secrets structure");100			data.secrets101				.write()102				.expect("no poisoning")103				.merge_from_hosts(std::mem::take(&mut data.host_secrets));104		}105		Ok(data)106	}107}108109/// Returns None if recipients.is_empty()110pub fn encrypt_secret_data<'r>(111	recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,112	data: Vec<u8>,113) -> Option<SecretData> {114	let mut encrypted = vec![];115	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))116		.ok()?117		.wrap_output(&mut encrypted)118		.expect("in memory write");119	io::copy(&mut Cursor::new(data), &mut encryptor).expect("in memory copy");120	encryptor.finish().expect("in memory flush");121	Some(SecretData {122		data: encrypted,123		encrypted: true,124	})125}126127#[derive(Serialize, Deserialize, Clone, Debug)]128pub struct FleetSecretPart {129	pub raw: SecretData,130}131132#[derive(Serialize, Deserialize, Clone, Debug)]133#[serde(rename_all = "camelCase")]134#[must_use]135pub struct FleetSecretData {136	pub created_at: DateTime<Utc>,137	#[serde(default, skip_serializing_if = "Option::is_none", alias = "expire_at")]138	pub expires_at: Option<DateTime<Utc>>,139140	#[serde(flatten)]141	pub parts: BTreeMap<String, FleetSecretPart>,142143	#[serde(default, skip_serializing_if = "Value::is_null")]144	pub generation_data: Value,145}146147fn is_false(b: &bool) -> bool {148	*b == false149}150151#[derive(Serialize, Deserialize, Clone, Debug, PartialOrd, Ord, PartialEq, Eq)]152#[repr(transparent)]153pub struct SecretOwner(String);154155impl fmt::Display for SecretOwner {156	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {157		write!(f, "host:{}", self.0)158	}159}160161impl SecretOwner {162	pub fn host(s: impl AsRef<str>) -> SecretOwner {163		SecretOwner(s.as_ref().to_owned())164	}165	pub fn as_host(&self) -> Option<&str> {166		Some(&self.0)167	}168}169170#[derive(Serialize, Deserialize, Clone, Debug)]171#[serde(rename_all = "camelCase")]172#[must_use]173pub struct FleetSecretDistribution {174	#[serde(default)]175	owners: BTreeSet<SecretOwner>,176	#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]177	owners_pending_prune: BTreeMap<SecretOwner, String>,178179	#[serde(flatten)]180	pub secret: FleetSecretData,181182	#[serde(default, skip_serializing_if = "Option::is_none")]183	pending_prune: Option<String>,184	#[serde(default, skip_serializing, alias = "managed")]185	_deprecated_managed: bool,186}187188const EMPTY_PENDING_PRUNE: &BTreeMap<SecretOwner, String> = &BTreeMap::new();189impl FleetSecretDistribution {190	pub fn new(owners: BTreeSet<SecretOwner>, secret: FleetSecretData, now: DateTime<Utc>) -> Self {191		assert!(192			!owners.is_empty(),193			"distribution should have at least one owner"194		);195		if let Some(expires_at) = &secret.expires_at {196			assert!(197				*expires_at > now,198				"secret should not be expired on creation"199			);200		}201		Self {202			owners,203			secret,204			owners_pending_prune: BTreeMap::new(),205			pending_prune: None,206			_deprecated_managed: true,207		}208	}209210	fn owners_ex(&self, including_pruned: bool) -> impl Iterator<Item = &SecretOwner> {211		let pending_prune = if including_pruned {212			&self.owners_pending_prune213		} else {214			EMPTY_PENDING_PRUNE215		};216		self.owners.iter().chain(pending_prune.keys())217	}218	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {219		self.owners_ex(false)220	}221222	pub fn prune(&mut self, reason: String) {223		assert!(224			self.pending_prune.is_none(),225			"it shouldn't be possible to prune the same distribution twice using public api"226		);227		self.pending_prune = Some(reason);228	}229	pub fn prune_owners(&mut self, owners: &BTreeSet<SecretOwner>, reason: String) {230		// if self.owners.iter().all(|o| owners.contains(o)) && self.owners_pending_prune.is_empty() {231		// 	self.prune(format!("all owners were pruned: {reason}"));232		// 	return;233		// }234		for owner in owners {235			if self.owners.remove(owner) {236				self.owners_pending_prune237					.insert(owner.to_owned(), reason.clone());238			}239		}240		// if self.owners.is_empty() {241		// 	self.prune("no owners left".to_owned());242		// }243	}244	pub fn unprune_owner(&mut self, owner: SecretOwner) {245		if self.owners_pending_prune.remove(&owner).is_some() {246			self.owners.insert(owner);247		}248	}249}250251#[derive(Clone, Debug, Default)]252#[must_use]253pub struct FleetSecretDistributions {254	stored: Vec<FleetSecretDistribution>,255}256257fn compare_dists(258	a: &FleetSecretDistribution,259	b: &FleetSecretDistribution,260	prefer_identities: &BTreeSet<SecretOwner>,261	include_pruned_owners: bool,262) -> Ordering {263	use Ordering::*;264	if prefer_identities.is_empty() {265		let a_has = a266			.owners_ex(include_pruned_owners)267			.any(|o| prefer_identities.contains(o));268		let b_has = b269			.owners_ex(include_pruned_owners)270			.any(|o| prefer_identities.contains(o));271		match (a_has, b_has) {272			(true, false) => return Greater,273			(false, true) => return Less,274			_ => {}275		}276	}277	match (a.secret.expires_at, b.secret.expires_at) {278		(None, Some(_)) => return Greater,279		(Some(_), None) => return Less,280		(Some(a), Some(b)) => {281			// Later is better282			return a.cmp(&b);283		}284		(None, None) => {}285	}286287	// Which one is easier to access288	return a.owners.len().cmp(&b.owners.len());289}290291impl FleetSecretDistributions {292	/// Drop expired distributions293	fn prune_expired(&mut self, now: DateTime<Utc>) {294		for ele in self.distributions_mut() {295			if let Some(expires_at) = ele.secret.expires_at {296				if expires_at < now {297					ele.prune(format!("expired during check at {now}"));298				}299			}300		}301	}302	/// Perform all pruning relevant to shared secrets303	/// Also see expected_owner_removed304	pub fn prune_shared(305		&mut self,306		expected_owners: &BTreeSet<SecretOwner>,307		unique: bool,308		expected_parts: &BTreeMap<String, GeneratorPart>,309		expected_generation_data: &Value,310		regenerate_on_owner_removed: bool,311		regenerate_on_owner_added: bool,312		prefer_identities: &BTreeSet<SecretOwner>,313		now: DateTime<Utc>,314	) {315		self.prune_expired(now);316		self.prune_generation_data(expected_generation_data, None);317		self.prune_missing_parts(expected_parts, None);318319		let current_owners = self.owners().cloned().collect::<BTreeSet<SecretOwner>>();320321		let mut to_add = expected_owners.difference(&current_owners);322		if to_add.next().is_some() && unique && regenerate_on_owner_added {323			for dist in self.distributions_mut() {324				dist.prune(format!(325					"owners missing, can't add new distribution, regeneration preferred"326				));327			}328			return;329		}330331		for to_remove in current_owners.difference(&expected_owners) {332			self.entry(to_remove.clone()).remove(333				regenerate_on_owner_removed,334				"owner was removed from expected owners list, regenerate_on_owner_removed is set"335					.to_string(),336			);337		}338		if unique {339			self.prune_nonunique(prefer_identities);340		}341	}342	pub fn prune_host(343		&mut self,344		owner: SecretOwner,345		expected_parts: &BTreeMap<String, GeneratorPart>,346		expected_generation_data: &Value,347		now: DateTime<Utc>,348	) {349		self.prune_expired(now);350		self.prune_generation_data(expected_generation_data, Some(&owner));351		// TODO: Owner-based pruning is warranted (e.g host no longer has secret defined)352		self.prune_missing_parts(expected_parts, Some(&owner));353	}354	/// Position of best distributions as in iterator returned by distributions()355	/// None if distributions not found356	fn best_idx(357		&self,358		prefer_identities: &BTreeSet<SecretOwner>,359		include_pruned_owners: bool,360	) -> Option<usize> {361		self.distributions()362			.enumerate()363			.max_by(|(_, a), (_, b)| {364				compare_dists(&a, &b, prefer_identities, include_pruned_owners)365			})366			.map(|(p, _)| p)367	}368	/// Secret wants to be the same on all hosts, leave only one unpruned version of it369	fn prune_nonunique(&mut self, prefer_identities: &BTreeSet<SecretOwner>) {370		if self.distributions().next().is_none() {371			return;372		}373		let best = self.best_idx(prefer_identities, false).expect("not empty");374		for (i, dist) in self.distributions_mut().enumerate() {375			if i != best {376				dist.prune(377					"secret wants to be the same on all hosts, only the best one was left"378						.to_owned(),379				);380			}381		}382	}383384	pub fn try_unprune(&mut self, owner: SecretOwner) -> Option<&FleetSecretDistribution> {385		assert!(self.get(&owner).is_none(), "secret is not pruned for host");386		if let Some(dist) = self387			.distributions_mut()388			.find(|v| v.owners_pending_prune.contains_key(&owner))389		{390			dist.unprune_owner(owner);391			Some(dist)392		} else {393			None394		}395	}396397	pub fn best_distribution_for_reencryption(398		&mut self,399		prefer_identities: &BTreeSet<SecretOwner>,400	) -> Option<&mut FleetSecretDistribution> {401		let best_idx = self.best_idx(prefer_identities, true)?;402		self.distributions_mut().nth(best_idx)403	}404405	fn prune_missing_parts(406		&mut self,407		expected_parts: &BTreeMap<String, GeneratorPart>,408		filter_owner: Option<&SecretOwner>,409	) {410		'dist: for ele in self.distributions_mut() {411			if let Some(filter_owner) = filter_owner {412				if !ele.owners.contains(filter_owner) {413					continue;414				}415				// Note: secret still can have multiple owners even if it is host-owned416				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them417			}418			for (name, part) in expected_parts {419				let Some(stored_part) = ele.secret.parts.get(name) else {420					ele.prune(format!("secret definition added new part: {name}"));421					continue 'dist;422				};423				if part.encrypted != stored_part.raw.encrypted {424					ele.prune(format!(425						"secret definition now requires part to be {}",426						if part.encrypted {427							"encrypted"428						} else {429							"non-encrypted"430						}431					));432					continue 'dist;433				}434			}435		}436	}437	fn prune_generation_data(438		&mut self,439		expected_generation_data: &Value,440		filter_owner: Option<&SecretOwner>,441	) {442		for ele in self.distributions_mut() {443			if let Some(filter_owner) = filter_owner {444				if !ele.owners.contains(filter_owner) {445					continue;446				}447				// Note: secret still can have multiple owners even if it is host-owned448				// in this case we expect that all owners using the same generator, so we can prune distribution for all of them449			}450			if ele.secret.generation_data != *expected_generation_data {451				ele.prune(format!(452					"expected generation data mismatch: {expected_generation_data:?}"453				));454			}455		}456	}457458	/// Prune all distributions with no unpruned owners.459	/// For ease of reencryption where possible, it is only called on persistence, when in memory - pruned owners are kept and460	/// can decrypt their secrets.461	fn prune_dead(&mut self) {462		for ele in self.distributions_mut() {463			if ele.owners.is_empty() {464				ele.prune("no owners left".to_owned());465			}466		}467	}468469	pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {470		self.stored.iter().filter(|v| v.pending_prune.is_none())471	}472	pub fn distributions_mut(&mut self) -> impl Iterator<Item = &mut FleetSecretDistribution> {473		self.stored.iter_mut().filter(|v| v.pending_prune.is_none())474	}475	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {476		self.distributions().flat_map(|v| v.owners.iter())477	}478	#[allow(479		clippy::len_without_is_empty,480		reason = "should not be empty for a long time"481	)]482	pub fn len(&self) -> usize {483		self.distributions().count()484	}485486	pub fn get(&self, owner: &SecretOwner) -> Option<&FleetSecretDistribution> {487		self.distributions().find(|d| d.owners.contains(owner))488	}489	fn entry(&mut self, owner: SecretOwner) -> DistEntry<'_> {490		let Some((idx, dist)) = self491			.distributions()492			.enumerate()493			.find(|(_, d)| d.owners.contains(&owner))494		else {495			return DistEntry::Vacant(VacantDistEntry {496				distributions: self,497				owners: BTreeSet::from([owner]),498			});499		};500		DistEntry::Occupied(OccupiedDistEntry {501			owners: dist.owners.clone(),502			distributions: self,503			idx,504		})505	}506	pub fn extend(&mut self, dist: FleetSecretDistribution, reason: String) {507		for ele in self.distributions_mut() {508			ele.prune_owners(&dist.owners, reason.clone());509		}510		self.stored.push(dist);511	}512	pub fn contains(&self, owner: &SecretOwner) -> bool {513		self.distributions().any(|d| d.owners.contains(owner))514	}515}516517struct OccupiedDistEntry<'d> {518	distributions: &'d mut FleetSecretDistributions,519	idx: usize,520	owners: BTreeSet<SecretOwner>,521}522impl<'d> OccupiedDistEntry<'d> {523	fn remove(self, whole_dist: bool, reason: String) -> VacantDistEntry<'d> {524		let dist = &mut self.distributions.stored[self.idx];525		if whole_dist {526			dist.prune(reason);527		} else {528			dist.prune_owners(&self.owners, reason);529		}530		VacantDistEntry {531			distributions: self.distributions,532			owners: self.owners,533		}534	}535	fn set(self, secret: FleetSecretData, reason: String) -> Self {536		self.remove(false, reason).set(secret)537	}538}539struct VacantDistEntry<'d> {540	distributions: &'d mut FleetSecretDistributions,541	owners: BTreeSet<SecretOwner>,542}543impl<'d> VacantDistEntry<'d> {544	fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {545		let Self {546			distributions,547			owners,548		} = self;549		let idx = distributions.stored.len();550		distributions.stored.push(FleetSecretDistribution {551			owners: owners.clone(),552			secret,553554			owners_pending_prune: BTreeMap::new(),555			pending_prune: None,556			_deprecated_managed: true,557		});558		OccupiedDistEntry {559			distributions,560			owners,561			idx,562		}563	}564}565566enum DistEntry<'d> {567	Vacant(VacantDistEntry<'d>),568	Occupied(OccupiedDistEntry<'d>),569}570impl DistEntry<'_> {571	fn remove(self, whole_dist: bool, reason: String) -> Self {572		match self {573			DistEntry::Vacant(_) => self,574			DistEntry::Occupied(o) => Self::Vacant(o.remove(whole_dist, reason)),575		}576	}577	fn set(self, secret: FleetSecretData, reason: String) -> Self {578		Self::Occupied(match self {579			DistEntry::Vacant(e) => e.set(secret),580			DistEntry::Occupied(e) => e.set(secret, reason),581		})582	}583}584585impl Serialize for FleetSecretDistributions {586	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>587	where588		S: serde::Serializer,589	{590		let mut v = self.clone();591		v.prune_dead();592		let mut found_hosts = BTreeSet::new();593		for ele in v.distributions() {594			if ele.pending_prune.is_some() {595				continue;596			}597			if ele.owners.is_empty() {598				panic!("consistency: secret distribution has no defined owners");599			}600			for ele in ele.owners.iter() {601				if !found_hosts.insert(ele) {602					panic!(603						"consistency: secret distribution contains duplicate entry for the same host",604					);605				}606			}607		}608		match v.stored.len() {609			0 => panic!("consistency: empty distributions"),610			1 => v.stored[0].serialize(serializer),611			_ => {612				let mut sorted = v.stored.clone();613				// Store outdated distributions last614				sorted.sort_by_key(|v| v.pending_prune.is_some() as u32);615				sorted.serialize(serializer)616			}617		}618	}619}620impl<'de> Deserialize<'de> for FleetSecretDistributions {621	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>622	where623		D: serde::Deserializer<'de>,624	{625		#[derive(Deserialize)]626		#[serde(untagged)]627		enum Distributions {628			One(FleetSecretDistribution),629			Many(Vec<FleetSecretDistribution>),630		}631		let d = Distributions::deserialize(deserializer)?;632		let stored = match d {633			Distributions::One(d) => vec![d],634			Distributions::Many(ds) => ds,635		};636		if stored.is_empty() {637			return Err(de::Error::custom("consistency: empty distributions"));638		}639		let mut found_hosts = BTreeSet::new();640		for ele in stored.iter() {641			if ele.pending_prune.is_some() {642				continue;643			}644			if ele.owners.is_empty() {645				return Err(de::Error::custom(646					"consistency: secret distribution has no defined owners",647				));648			}649			for ele in ele.owners.iter() {650				if !found_hosts.insert(ele) {651					return Err(de::Error::custom(652						"consistency: secret distribution contains duplicate entry for the same host",653					));654				}655			}656		}657		Ok(Self { stored })658	}659}660661#[derive(Deserialize, Default)]662pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);663664impl Serialize for FleetSecrets {665	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>666	where667		S: serde::Serializer,668	{669		let data: BTreeMap<String, FleetSecretDistributions> = self670			.0671			.iter()672			.filter(|(_, v)| !v.stored.is_empty())673			.map(|(k, v)| (k.clone(), v.clone()))674			.collect();675676		data.serialize(serializer)677	}678}679680impl FleetSecrets {681	pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {682		self.0.keys()683	}684685	pub fn keys_for_owner(&self, owner: &SecretOwner) -> impl Iterator<Item = &String> {686		self.0687			.iter()688			.filter(|(_, d)| d.contains(owner))689			.map(|(n, _)| n)690	}691692	pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {693		match self.0.entry(secret) {694			Entry::Vacant(e) => {695				e.insert(FleetSecretDistributions { stored: vec![data] });696			}697			Entry::Occupied(mut e) => {698				let dists = e.get_mut();699				dists.extend(data, "secret data was replaced".to_owned())700			}701		}702	}703	pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {704		self.0.get(secret)705	}706	pub fn get_mut(&mut self, secret: &str) -> Option<&mut FleetSecretDistributions> {707		self.0.get_mut(secret)708	}709710	pub fn get_or_create(&mut self, secret: &str) -> &mut FleetSecretDistributions {711		self.0712			.entry(secret.to_owned())713			.or_insert(FleetSecretDistributions::default())714	}715716	pub fn contains(&self, secret: &str) -> bool {717		self.0.contains_key(secret)718	}719	pub fn remove(&mut self, secret: &str) {720		self.0.remove(secret);721	}722723	fn merge_from_hosts(724		&mut self,725		host_secrets: BTreeMap<SecretOwner, BTreeMap<String, FleetSecretDistribution>>,726	) {727		for (host, host_secrets) in host_secrets {728			for (secret_name, mut secret_data) in host_secrets {729				secret_data.owners.insert(host.clone());730				self.set_data(secret_name, secret_data);731			}732		}733	}734735	pub fn prune_host(&mut self, host: &SecretOwner, expected_nonshared: BTreeSet<String>) {736		for (name, dists) in self.0.iter_mut() {737			if expected_nonshared.contains(name) {738				continue;739			}740			for dist in dists.distributions_mut() {741				if dist.owners.contains(host) {742					dist.prune_owners(743						&BTreeSet::from([host.to_owned()]),744						"host no longer defines this secret".to_owned(),745					);746				}747			}748		}749	}750}751752#[derive(Debug, Clone)]753pub struct Expectations {754	pub owners: BTreeSet<SecretOwner>,755	pub generation_data: serde_json::Value,756	pub parts: BTreeMap<String, GeneratorPart>,757}758#[derive(Deserialize, Debug, Clone)]759pub struct GeneratorPart {760	pub encrypted: bool,761}762763#[derive(Debug, Clone, Copy)]764pub struct RegenerationConstraints {765	pub allow_different: bool,766	pub regenerate_on_owner_added: bool,767	pub regenerate_on_owner_removed: bool,768}769impl RegenerationConstraints {770	pub fn host_personal() -> Self {771		Self {772			allow_different: false,773			regenerate_on_owner_added: true,774			regenerate_on_owner_removed: true,775		}776	}777	pub fn without_preferences(self) -> Self {778		Self {779			allow_different: self.allow_different,780			regenerate_on_owner_added: false,781			regenerate_on_owner_removed: false,782		}783	}784}
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -1,6 +1,5 @@
 use std::{
-	cell::OnceCell,
-	collections::BTreeSet,
+	collections::{BTreeMap, BTreeSet},
 	ffi::{OsStr, OsString},
 	fmt::Display,
 	io::Write,
@@ -11,6 +10,7 @@
 };
 
 use anyhow::{Context, Result, anyhow, bail, ensure};
+use chrono::{DateTime, Utc};
 use fleet_shared::SecretData;
 use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};
 use openssh::{ControlPersist, SessionBuilder};
@@ -22,15 +22,20 @@
 
 use crate::{
 	command::MyCommand,
-	fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},
+	fleetdata::{
+		FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,
+	},
 };
 
 pub struct FleetConfigInternals {
+	pub prefer_identities: BTreeSet<SecretOwner>,
+	pub now: DateTime<Utc>,
+
 	/// Fleet project directory, containing fleet.nix file.
 	pub directory: PathBuf,
 	/// builtins.currentSystem
 	pub local_system: String,
-	pub data: Arc<Mutex<FleetData>>,
+	pub data: Arc<FleetData>,
 	pub nix_args: Vec<OsString>,
 	/// fleet_config.config
 	pub config_field: Value,
@@ -96,16 +101,16 @@
 pub struct ConfigHost {
 	config: Config,
 	pub name: String,
-	groups: OnceCell<Vec<String>>,
+	groups: OnceLock<Vec<String>>,
 
 	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it
-	deploy_kind: OnceCell<DeployKind>,
-	session_destination: OnceCell<String>,
-	legacy_ssh_store: OnceCell<bool>,
+	deploy_kind: OnceLock<DeployKind>,
+	session_destination: OnceLock<String>,
+	legacy_ssh_store: OnceLock<bool>,
 
 	pub host_config: Option<Value>,
-	pub nixos_config: OnceCell<Value>,
-	pub nixos_unchecked_config: OnceCell<Value>,
+	pub nixos_config: OnceLock<Value>,
+	pub nixos_unchecked_config: OnceLock<Value>,
 	pub pkgs_override: Option<Value>,
 
 	// TODO: Move command helpers away with connectivity refactor
@@ -397,7 +402,38 @@
 		ensure!(!data.encrypted, "secret came out encrypted");
 		Ok(data.data)
 	}
-	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {
+	pub async fn reencrypt_distribution(
+		&self,
+		data: &FleetSecretDistribution,
+		targets: BTreeSet<SecretOwner>,
+		now: DateTime<Utc>,
+	) -> Result<FleetSecretDistribution> {
+		let mut parts = BTreeMap::new();
+		for (part_name, part) in &data.secret.parts {
+			parts.insert(
+				part_name.clone(),
+				if part.raw.encrypted {
+					FleetSecretPart {
+						raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,
+					}
+				} else {
+					part.clone()
+				},
+			);
+		}
+		let secret = FleetSecretData {
+			created_at: data.secret.created_at,
+			expires_at: data.secret.expires_at,
+			generation_data: data.secret.generation_data.clone(),
+			parts,
+		};
+		Ok(FleetSecretDistribution::new(targets, secret, now))
+	}
+	pub async fn reencrypt(
+		&self,
+		data: SecretData,
+		targets: BTreeSet<SecretOwner>,
+	) -> Result<SecretData> {
 		ensure!(data.encrypted, "secret is not encrypted");
 		let mut cmd = self.cmd("fleet-install-secrets").await?;
 		cmd.arg("reencrypt").eqarg("--secret", data.to_string());
@@ -537,12 +573,25 @@
 	}
 }
 
+#[derive(Clone)]
 pub struct SharedSecretDefinition(Value);
 impl SharedSecretDefinition {
-	pub fn expected_owners(&self) -> Result<BTreeSet<String>> {
+	pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {
 		let secret = &self.0;
 		Ok(nix_go_json!(secret.expectedOwners))
 	}
+	pub fn allow_different(&self) -> Result<bool> {
+		let secret = &self.0;
+		Ok(nix_go_json!(secret.allowDifferent))
+	}
+	pub fn regenerate_on_owner_added(&self) -> Result<bool> {
+		let secret = &self.0;
+		Ok(nix_go_json!(secret.regenerateOnOwnerAdded))
+	}
+	pub fn regenerate_on_owner_removed(&self) -> Result<bool> {
+		let secret = &self.0;
+		Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))
+	}
 	pub fn generator(&self) -> Result<Value> {
 		let secret = &self.0;
 		Ok(nix_go!(secret.generator))
@@ -572,10 +621,10 @@
 			config: self.clone(),
 			name: "<virtual localhost>".to_owned(),
 			host_config: None,
-			nixos_config: OnceCell::new(),
-			nixos_unchecked_config: OnceCell::new(),
+			nixos_config: OnceLock::new(),
+			nixos_unchecked_config: OnceLock::new(),
 			groups: {
-				let cell = OnceCell::new();
+				let cell = OnceLock::new();
 				let _ = cell.set(vec![]);
 				cell
 			},
@@ -583,9 +632,9 @@
 
 			local: true,
 			session: OnceLock::new(),
-			deploy_kind: OnceCell::new(),
-			session_destination: OnceCell::new(),
-			legacy_ssh_store: OnceCell::new(),
+			deploy_kind: OnceLock::new(),
+			session_destination: OnceLock::new(),
+			legacy_ssh_store: OnceLock::new(),
 		}
 	}
 
@@ -597,17 +646,17 @@
 			config: self.clone(),
 			name: name.to_owned(),
 			host_config: Some(host_config),
-			nixos_config: OnceCell::new(),
-			nixos_unchecked_config: OnceCell::new(),
-			groups: OnceCell::new(),
+			nixos_config: OnceLock::new(),
+			nixos_unchecked_config: OnceLock::new(),
+			groups: OnceLock::new(),
 			pkgs_override: None,
 
 			// TODO: Remove with connectivit refactor
 			local: self.localhost == name,
 			session: OnceLock::new(),
-			deploy_kind: OnceCell::new(),
-			session_destination: OnceCell::new(),
-			legacy_ssh_store: OnceCell::new(),
+			deploy_kind: OnceLock::new(),
+			session_destination: OnceLock::new(),
+			legacy_ssh_store: OnceLock::new(),
 		})
 	}
 	pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {
@@ -623,55 +672,6 @@
 	pub fn system_config(&self, host: &str) -> Result<Value> {
 		let fleet_field = &self.config_field;
 		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))
-	}
-
-	/// Shared secrets configured in fleet.nix or in flake
-	pub fn list_configured_shared(&self) -> Result<Vec<String>> {
-		let config_field = &self.config_field;
-		nix_go!(config_field.sharedSecrets).list_fields()
-	}
-	pub fn has_shared(&self, name: &str) -> bool {
-		let data = self.data();
-		data.secrets.contains(name)
-	}
-	pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {
-		let mut data = self.data_mut();
-		data.secrets.set_data(name, shared);
-	}
-	pub fn remove_shared(&self, secret: &str) {
-		let mut data = self.data_mut();
-		data.secrets.remove(secret);
-	}
-
-	pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {
-		let data = self.data_mut();
-		data.secrets.keys_for_owner(host).cloned().collect()
-	}
-	pub fn list_secrets(&self) -> Vec<String> {
-		let data = self.data_mut();
-		data.secrets.keys().cloned().collect()
-	}
-
-	pub fn has_secret(&self, host: &str, secret: &str) -> bool {
-		let data = self.data();
-		data.secrets.contains_for_owner(secret, host)
-	}
-	pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {
-		let mut data = self.data_mut();
-		data.secrets.set_single_data(secret, host, value);
-	}
-	pub fn remove_secret(&self, host: &str, secret: &str) {
-		let mut data = self.data_mut();
-		data.secrets.drop_owner_no_reencrypt(secret, host);
-	}
-
-	pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {
-		let data = self.data();
-		data.secrets.get_single(secret, host).cloned()
-	}
-	pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {
-		let data = self.data();
-		data.secrets.get(secret).cloned()
 	}
 
 	pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {
@@ -685,22 +685,9 @@
 		))))
 	}
 
-	// TODO: Should this be something modifiable from other processes?
-	// E.g terraform provider might want to update FleetData (e.g secrets),
-	// and current implementation assumes only one process holds current fleet.nix
-	// Given that it is no longer needs to be a file for nix evaluation,
-	// maybe it can be a .nix file for persistence, but accessible only
-	// thru some shared state controller? Might it be stored in terraform
-	// state provider?
-	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {
-		self.data.lock().unwrap()
-	}
-	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {
-		self.data.lock().unwrap()
-	}
 	pub fn save(&self) -> Result<()> {
 		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.")?;
-		let data = nixlike::serialize(&self.data() as &FleetData)?;
+		let data = nixlike::serialize(&*self.data)?;
 		tempfile.write_all(
 			format!(
 				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"
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";
                   };
                 });