git.delta.rocks / jrsonnet / refs/commits / f5a8281dad2c

difftreelog

feat expected secret parts

tylvsszvYaroslav Bolyukin2025-10-26parent: #7120038.patch.diff
in: trunk

16 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1055,6 +1055,7 @@
  "shlex",
  "tabled",
  "tempfile",
+ "thiserror 2.0.17",
  "time",
  "tokio",
  "tokio-util",
@@ -1087,6 +1088,7 @@
  "serde_json",
  "tabled",
  "tempfile",
+ "thiserror 2.0.17",
  "time",
  "tokio",
  "tokio-util",
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -47,6 +47,7 @@
 nom = "8.0.0"
 opentelemetry = "0.30.0"
 opentelemetry_sdk = "0.30.0"
+thiserror.workspace = true
 tracing-indicatif = { version = "0.3", optional = true }
 tracing-opentelemetry = "0.31.0"
 
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -2,17 +2,18 @@
 	collections::{BTreeMap, BTreeSet, HashSet},
 	io::{self, Read, Write, stdin, stdout},
 	path::PathBuf,
-	slice,
 };
 
-use age::Recipient;
 use anyhow::{Context, Result, anyhow, bail, ensure};
 use chrono::{DateTime, Utc};
 use clap::Parser;
 use fleet_base::{
-	fleetdata::{FleetSecret, FleetSecretPart, FleetSharedSecret, encrypt_secret_data},
+	fleetdata::{
+		FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,
+	},
 	host::Config,
 	opts::FleetOpts,
+	secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},
 };
 use fleet_shared::SecretData;
 use nix_eval::{NixType, Value, nix_go, nix_go_json};
@@ -144,76 +145,55 @@
 		#[clap(short = 'p', long, default_value = "secret")]
 		part: String,
 	},
-}
-
-fn secret_needs_regeneration(
-	secret: &FleetSecret,
-	expected_generation_data: &serde_json::Value,
-) -> bool {
-	let data_is_expected = secret.generation_data == *expected_generation_data;
-	// TODO: Leeway?
-	let expired = secret
-		.expires_at
-		.map(|expiration| expiration < Utc::now())
-		.unwrap_or(false);
-	expired || !data_is_expected
 }
 
 #[allow(clippy::too_many_arguments)]
-#[tracing::instrument(skip(config, secret, field, prefer_identities))]
+#[tracing::instrument(skip(config, secret, definition, prefer_identities))]
 async fn maybe_regenerate_shared_secret(
 	secret_name: &str,
 	config: &Config,
 	mut secret: FleetSharedSecret,
-	field: Value,
-	expected_owners: &[String],
-	expected_generation_data: serde_json::Value,
+	definition: SharedSecretDefinition,
 	prefer_identities: &[String],
+	expectations: &Expectations,
 ) -> Result<FleetSharedSecret> {
-	let original_set = secret.owners.clone();
+	let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);
+	let value = definition.inner();
 
-	let set = original_set.iter().collect::<BTreeSet<_>>();
-	let expected_set = expected_owners.iter().collect::<BTreeSet<_>>();
-
-	let regeneration_required =
-		secret_needs_regeneration(&secret.secret, &expected_generation_data);
-
-	if set == expected_set && !regeneration_required {
-		info!("no need to update owner list, it is already correct");
-		return Ok(secret);
-	}
-
-	let should_regenerate = if regeneration_required {
-		info!("secret has its generation data changed, regeneration is required");
-		true
-	} else if set.difference(&expected_set).next().is_some() {
-		// TODO: Remove this warning for revokable secrets.
-		warn!(
-			"host was removed from secret owners, but until this host rebuild, the secret will still be stored on it."
-		);
-		nix_go_json!(field.regenerateOnOwnerRemoved)
-	} else if expected_set.difference(&set).next().is_some() {
-		nix_go_json!(field.regenerateOnOwnerAdded)
-	} else {
-		false
+	let (should_reencrypt, reason) = match reason {
+		Some(RegenerationReason::OwnersAdded(_)) => {
+			// Secret always needs to be reencrypted for new owners to be able to read it
+			(
+				true,
+				if nix_go_json!(value.regenerateOnOwnerAdded) {
+					reason
+				} else {
+					None
+				},
+			)
+		}
+		Some(RegenerationReason::OwnersRemoved(_)) => {
+			// No need to reencrypt, we can just leave stanzas in place.
+			if nix_go_json!(value.regenerateOnOwnerRemoved) {
+				(true, reason)
+			} else {
+				(false, None)
+			}
+		}
+		Some(_) => (true, reason),
+		None => (false, None),
 	};
 
-	if should_regenerate {
-		info!("secret needs to be regenerated");
-		let generated = generate_shared(
-			config,
-			secret_name,
-			field,
-			expected_owners.to_vec(),
-			expected_generation_data,
-		)
-		.await?;
+	if let Some(reason) = reason {
+		info!("secret needs to be regenerated: {reason}");
+		let generated = generate_shared(config, secret_name, definition, expectations).await?;
 		Ok(generated)
-	} else {
+	} else if should_reencrypt {
+		info!("secret needs to be reencrypted");
 		let identity_holder = if !prefer_identities.is_empty() {
 			prefer_identities
 				.iter()
-				.find(|i| original_set.iter().any(|s| s == *i))
+				.find(|i| secret.owners.iter().any(|s| s == *i))
 		} else {
 			secret.owners.first()
 		};
@@ -228,12 +208,16 @@
 			}
 			let host = config.host(identity_holder).await?;
 			let encrypted = host
-				.reencrypt(part.raw.clone(), expected_owners.to_vec())
+				.reencrypt(
+					part.raw.clone(),
+					expectations.owners.iter().cloned().collect(),
+				)
 				.await?;
 			part.raw = encrypted;
 		}
-
-		secret.owners = expected_owners.to_vec();
+		secret.owners = expectations.owners.clone();
+		Ok(secret)
+	} else {
 		Ok(secret)
 	}
 }
@@ -250,8 +234,8 @@
 	_display_name: &str,
 	_secret: Value,
 	_default_generator: Value,
-	_owners: &[String],
-) -> Result<FleetSecret> {
+	_expectations: &Expectations,
+) -> Result<FleetSecretData> {
 	bail!("pure generators are broken for now")
 }
 async fn generate_impure(
@@ -259,9 +243,8 @@
 	_display_name: &str,
 	secret: Value,
 	default_generator: Value,
-	expected_owners: &[String],
-	expected_generation_data: serde_json::Value,
-) -> Result<FleetSecret> {
+	expectations: &Expectations,
+) -> Result<FleetSecretData> {
 	let generator = nix_go!(secret.generator);
 	let on: Option<String> = nix_go_json!(default_generator.impureOn);
 
@@ -276,12 +259,11 @@
 	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);
 
 	let mut recipients = Vec::new();
-	for owner in expected_owners {
+	for owner in &expectations.owners {
 		let key = config.key(owner).await?;
 		recipients.push(key);
 	}
 	let generators = nix_go!(mk_secret_generators(Obj { recipients }));
-	// FIXME: Apparently, // operator is slow in nix
 	let pkgs_and_generators = on_pkgs.attrs_update(generators)?;
 
 	let call_package = nix_go!(nixpkgs.lib.callPackageWith(pkgs_and_generators));
@@ -331,20 +313,26 @@
 	let created_at = host.read_file_value(format!("{out}/created_at")).await?;
 	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();
 
-	Ok(FleetSecret {
+	let new_data = FleetSecretData {
 		created_at,
 		expires_at,
 		parts,
-		generation_data: expected_generation_data,
-	})
+		generation_data: expectations.generation_data.clone(),
+	};
+
+	if let Some(reason) = secret_needs_regeneration(&new_data, &expectations.owners, expectations) {
+		bail!("newly generated secret needs to be regenerated: {reason}")
+	}
+
+	Ok(new_data)
 }
+
 async fn generate(
 	config: &Config,
 	display_name: &str,
 	secret: Value,
-	expected_owners: &[String],
-	expected_generation_data: serde_json::Value,
-) -> Result<FleetSecret> {
+	expectations: &Expectations,
+) -> Result<FleetSecretData> {
 	let generator = nix_go!(secret.generator);
 	// Can't properly check on nix module system level
 	{
@@ -388,8 +376,7 @@
 				display_name,
 				secret,
 				default_generator,
-				expected_owners,
-				expected_generation_data,
+				expectations,
 			)
 			.await
 		}
@@ -399,7 +386,7 @@
 				display_name,
 				secret,
 				default_generator,
-				expected_owners,
+				expectations,
 			)
 			.await
 		}
@@ -408,21 +395,14 @@
 async fn generate_shared(
 	config: &Config,
 	display_name: &str,
-	secret: Value,
-	expected_owners: Vec<String>,
-	expected_generation_data: serde_json::Value,
+	secret: SharedSecretDefinition,
+	expectations: &Expectations,
 ) -> Result<FleetSharedSecret> {
 	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
 	Ok(FleetSharedSecret {
-		secret: generate(
-			config,
-			display_name,
-			secret,
-			&expected_owners,
-			expected_generation_data,
-		)
-		.await?,
-		owners: expected_owners,
+		managed: Some(true),
+		secret: generate(config, display_name, secret.inner(), expectations).await?,
+		owners: expectations.owners.clone(),
 	})
 }
 
@@ -457,11 +437,11 @@
 }
 
 fn parse_machines(
-	initial: Vec<String>,
+	initial: BTreeSet<String>,
 	machines: Option<Vec<String>>,
 	mut add_machines: Vec<String>,
 	mut remove_machines: Vec<String>,
-) -> Result<Vec<String>> {
+) -> Result<BTreeSet<String>> {
 	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {
 		bail!("no operation");
 	}
@@ -470,7 +450,6 @@
 	let mut target_machines = initial;
 	info!("Currently encrypted for {initial_machines:?}");
 
-	// ensure!(machines.is_some() || !add_machines.is_empty() || )
 	if let Some(machines) = machines {
 		ensure!(
 			add_machines.is_empty() && remove_machines.is_empty(),
@@ -487,20 +466,13 @@
 	}
 
 	for machine in &remove_machines {
-		let mut removed = false;
-		while let Some(pos) = target_machines.iter().position(|m| m == machine) {
-			target_machines.swap_remove(pos);
-			removed = true;
-		}
-		if !removed {
+		if !target_machines.remove(machine) {
 			warn!("secret is not enabled for {machine}");
 		}
 	}
 	for machine in &add_machines {
-		if target_machines.iter().any(|m| m == machine) {
+		if !target_machines.insert(machine.to_owned()) {
 			warn!("secret is already added to {machine}");
-		} else {
-			target_machines.push(machine.to_owned());
 		}
 	}
 	if !remove_machines.is_empty() {
@@ -527,7 +499,7 @@
 				}
 			}
 			Secret::AddShared {
-				mut machines,
+				machines,
 				name,
 				force,
 				public,
@@ -537,25 +509,32 @@
 				re_add,
 				part: part_name,
 			} => {
+				let mut machines: BTreeSet<String> = machines.into_iter().collect();
 				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
 
-				let exists = config.has_shared(&name);
-				if exists && !force && !re_add {
-					bail!("secret already defined");
-				}
-				if re_add {
-					// Fixme: use clap to limit this usage
-					ensure!(!force, "--force and --readd are not compatible");
-					ensure!(exists, "secret doesn't exists");
-					ensure!(
-						machines.is_empty(),
-						"you can't use machines argument for --readd"
-					);
-					let shared = config.shared_secret(&name)?;
-					machines = shared.owners;
-				}
+				if let Some(old_shared) = config.shared_secret(&name)? {
+					if !force && !re_add {
+						bail!("secret already defined");
+					};
+					if old_shared.managed.unwrap_or(false) {
+						bail!("secret is marked as managed, should not be updated manually");
+					};
+					if re_add {
+						// Fixme: use clap to limit this usage
+						ensure!(!force, "--force and --readd are not compatible");
+						ensure!(
+							machines.is_empty(),
+							"you can't use machines argument for --readd"
+						);
+						machines = old_shared.owners;
+					}
+				} else if re_add {
+					bail!("secret doesn't exists");
+				};
 
-				let recipients = config.recipients(machines.clone()).await?;
+				let recipients = config
+					.recipients(machines.iter().cloned().collect())
+					.await?;
 
 				let mut parts = BTreeMap::new();
 
@@ -563,9 +542,8 @@
 				io::stdin().read_to_end(&mut input)?;
 
 				if !input.is_empty() {
-					let encrypted =
-						encrypt_secret_data(recipients.iter().map(|r| r as &dyn Recipient), input)
-							.ok_or_else(|| anyhow!("no recipients provided"))?;
+					let encrypted = encrypt_secret_data(recipients.iter(), input)
+						.ok_or_else(|| anyhow!("no recipients provided"))?;
 					parts.insert(part_name, FleetSecretPart { raw: encrypted });
 				}
 
@@ -576,8 +554,9 @@
 				config.replace_shared(
 					name,
 					FleetSharedSecret {
+						managed: Some(false),
 						owners: machines,
-						secret: FleetSecret {
+						secret: FleetSecretData {
 							created_at: Utc::now(),
 							expires_at,
 							parts,
@@ -607,34 +586,47 @@
 						.host_secret(&machine, &name)
 						.context("failed to read existing secret for --merge")?
 				} else {
-					FleetSecret {
-						created_at: Utc::now(),
-						expires_at: None,
-						parts: BTreeMap::new(),
-						generation_data: serde_json::Value::Null,
+					FleetHostSecret {
+						managed: Some(false),
+						secret: FleetSecretData {
+							created_at: Utc::now(),
+							expires_at: None,
+							parts: BTreeMap::new(),
+							generation_data: serde_json::Value::Null,
+						},
 					}
 				};
+				if out.managed.unwrap_or(false) {
+					bail!("secret is managed by fleet and should not be updated manually");
+				}
+				out.managed = Some(false);
 
 				if let Some(secret) = parse_secret().await? {
 					let recipient = config.recipient(&machine).await?;
-					let encrypted = encrypt_secret_data([&recipient as &dyn Recipient], secret)
-						.expect("recipient provided");
+					let encrypted =
+						encrypt_secret_data([&recipient], secret).expect("recipient provided");
 					if out
+						.secret
 						.parts
 						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })
 						.is_some() && !replace
 					{
-						bail!("part {part_name:?} is already defined");
+						bail!(
+							"part {part_name:?} is already defined, use --replace if you wish to replace it"
+						);
 					}
 				}
 
 				if let Some(public) = parse_public(public, public_file).await? {
 					if out
+						.secret
 						.parts
 						.insert(public_name.clone(), FleetSecretPart { raw: public })
 						.is_some() && !replace
 					{
-						bail!("part {public_name:?} is already defined");
+						bail!(
+							"part {public_name:?} is already defined, use --replace if you wish to replace it"
+						);
 					}
 				};
 
@@ -647,7 +639,7 @@
 				part: part_name,
 			} => {
 				let secret = config.host_secret(&machine, &name)?;
-				let Some(secret) = secret.parts.get(&part_name) else {
+				let Some(secret) = secret.secret.parts.get(&part_name) else {
 					bail!("no part {part_name} in secret {name}");
 				};
 				let data = if secret.raw.encrypted {
@@ -664,7 +656,9 @@
 				part: part_name,
 				prefer_identities,
 			} => {
-				let secret = config.shared_secret(&name)?;
+				let Some(secret) = config.shared_secret(&name)? else {
+					bail!("secret doesn't exists");
+				};
 				let Some(part) = secret.secret.parts.get(&part_name) else {
 					bail!("no part {part_name} in secret {name}");
 				};
@@ -695,7 +689,9 @@
 			} => {
 				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
 
-				let secret = config.shared_secret(&name)?;
+				let Some(secret) = config.shared_secret(&name)? else {
+					bail!("secret doesn't exists");
+				};
 				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {
 					bail!("no secret");
 				}
@@ -714,20 +710,16 @@
 					return Ok(());
 				}
 
-				let config_field = &config.config_field;
-				let name_clone = name.clone();
-				let field = nix_go!(config_field.sharedSecrets[name_clone]);
-				let expected_generation_data = nix_go_json!(field.expectedGenerationData);
+				let definition = config.shared_secret_definition(&name)?;
+				let expectations = definition.expectations()?;
 
 				let updated = maybe_regenerate_shared_secret(
 					&name,
 					config,
 					secret,
-					field,
-					&target_machines,
-					expected_generation_data,
+					definition,
 					&prefer_identities,
-					// None,
+					&expectations,
 				)
 				.await?;
 				config.replace_shared(name, updated);
@@ -737,36 +729,26 @@
 				skip_hosts,
 			} => {
 				info!("checking for secrets to regenerate");
+				let expected_shared_set = config
+					.list_configured_shared()
+					.await?
+					.into_iter()
+					.collect::<HashSet<_>>();
 				let stored_shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
 				{
 					// Generate missing shared
 					let _span = info_span!("shared").entered();
-					let expected_shared_set = config
-						.list_configured_shared()
-						.await?
-						.into_iter()
-						.collect::<HashSet<_>>();
 					for missing in expected_shared_set.difference(&stored_shared_set) {
-						let config_field = &config.config_field;
-						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);
-						let expected_generation_data: serde_json::Value =
-							nix_go_json!(secret.expectedGenerationData);
-						let expected_owners: Option<Vec<String>> =
-							nix_go_json!(secret.expectedOwners);
-						let Some(expected_owners) = expected_owners else {
-							// Can't generate this missing secret, as it has no defined owners.
+						let definition = config.shared_secret_definition(missing)?;
+						if !definition.is_managed()? {
+							info!("skipping unmanaged secret: {missing}");
 							continue;
-						};
+						}
+						let expectations = definition.expectations()?;
 						info!("generating secret: {missing}");
-						let shared = generate_shared(
-							config,
-							missing,
-							secret,
-							expected_owners,
-							expected_generation_data,
-						)
-						.in_current_span()
-						.await?;
+						let shared = generate_shared(config, missing, definition, &expectations)
+							.in_current_span()
+							.await?;
 						config.replace_shared(missing.to_string(), shared)
 					}
 				}
@@ -778,26 +760,22 @@
 
 						let _span = info_span!("host", host = host.name).entered();
 						let expected_set = host
-							.list_configured_secrets()
-							.in_current_span()
-							.await?
+							.list_defined_secrets()?
 							.into_iter()
 							.collect::<HashSet<_>>();
 						let stored_set = config
 							.list_secrets(&host.name)
 							.into_iter()
 							.collect::<HashSet<_>>();
-						for missing in expected_set.difference(&stored_set) {
-							info!("generating secret: {missing}");
-							let secret = host.secret_field(missing).in_current_span().await?;
-							let expected_generation_data =
-								nix_go_json!(secret.expectedGenerationData);
+						for missing_secret in expected_set.difference(&stored_set) {
+							info!("generating missing secret: {missing_secret}");
+							let definition = host.secret_definition(missing_secret)?;
+							let expectations = definition.expectations()?;
 							let generated = match generate(
 								config,
-								missing,
-								secret,
-								slice::from_ref(&host.name),
-								expected_generation_data,
+								missing_secret,
+								definition.inner(),
+								&expectations,
 							)
 							.in_current_span()
 							.await
@@ -808,21 +786,27 @@
 									continue;
 								}
 							};
-							config.insert_secret(&host.name, missing.to_string(), generated)
+							config.insert_secret(
+								&host.name,
+								missing_secret.to_string(),
+								FleetHostSecret {
+									managed: Some(true),
+									secret: generated,
+								},
+							)
 						}
-						for name in stored_set {
-							info!("updating secret: {name}");
-							let data = config.host_secret(&host.name, &name)?;
-							let secret = host.secret_field(&name).in_current_span().await?;
-							let expected_generation_data =
-								nix_go_json!(secret.expectedGenerationData);
-							if secret_needs_regeneration(&data, &expected_generation_data) {
+						for known_secret in stored_set.intersection(&expected_set) {
+							info!("updating secret: {known_secret}");
+							let data = config.host_secret(&host.name, known_secret)?;
+							let definition = host.secret_definition(known_secret)?;
+							let expectations = definition.expectations()?;
+							if let Some(regen_reason) = data.needs_regeneration(&expectations) {
+								info!("needs regeneration: {regen_reason}");
 								let generated = match generate(
 									config,
-									&name,
-									secret,
-									slice::from_ref(&host.name),
-									expected_generation_data,
+									known_secret,
+									definition.inner(),
+									&expectations,
 								)
 								.in_current_span()
 								.await
@@ -833,43 +817,44 @@
 										continue;
 									}
 								};
-								config.insert_secret(&host.name, name.to_string(), generated)
+								config.insert_secret(
+									&host.name,
+									known_secret.to_string(),
+									FleetHostSecret {
+										managed: Some(true),
+										secret: generated,
+									},
+								)
 							}
 						}
+						for removed_secret in stored_set.difference(&expected_set) {
+							info!("removing secret: {removed_secret}");
+							config.remove_secret(&host.name, removed_secret);
+						}
 					}
 				}
-				let mut to_remove = Vec::new();
-				for name in &stored_shared_set {
-					info!("updating secret: {name}");
-					let data = config.shared_secret(name)?;
-					let config_field = &config.config_field;
-					let expected_owners: Option<Vec<String>> =
-						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);
-					let Some(expected_owners) = expected_owners else {
-						warn!("secret was removed from fleet config: {name}, removing from data");
-						to_remove.push(name.to_string());
-						continue;
-					};
+				for known_secret in stored_shared_set.intersection(&expected_shared_set) {
+					info!("updating shared secret: {known_secret}");
+					let data = config.shared_secret(known_secret)?.expect("exists");
 
-					let secret = nix_go!(config_field.sharedSecrets[{ name }]);
-					let expected_generation_data = nix_go_json!(secret.expectedGenerationData);
+					let definition = config.shared_secret_definition(known_secret)?;
+					let expectations = definition.expectations()?;
 					config.replace_shared(
-						name.to_owned(),
+						known_secret.to_owned(),
 						maybe_regenerate_shared_secret(
-							name,
+							known_secret,
 							config,
 							data,
-							secret,
-							&expected_owners,
-							expected_generation_data,
+							definition,
 							&prefer_identities,
-							// None,
+							&expectations,
 						)
 						.await?,
 					);
 				}
-				for k in to_remove {
-					config.remove_shared(&k);
+				for removed_secret in stored_shared_set.difference(&expected_shared_set) {
+					info!("removing shared secret: {removed_secret}");
+					config.remove_shared(removed_secret);
 				}
 			}
 			Secret::List {} => {
@@ -885,13 +870,14 @@
 				let mut table = vec![];
 				for name in configured.iter().cloned() {
 					let config = config.clone();
-					let expected_owners = config.shared_secret_expected_owners(&name).await?;
-					let data = config.shared_secret(&name)?;
+					let data = config.shared_secret(&name)?.expect("exists");
+					let definition = config.shared_secret_definition(&name)?;
+					let expectations = definition.expectations()?;
 					let owners = data
 						.owners
 						.iter()
 						.map(|o| {
-							if expected_owners.contains(o) {
+							if expectations.owners.contains(o) {
 								o.green().to_string()
 							} else {
 								o.red().to_string()
@@ -912,7 +898,7 @@
 				add,
 			} => {
 				let secret = config.host_secret(&machine, &name)?;
-				if let Some(data) = secret.parts.get(&part) {
+				if let Some(data) = secret.secret.parts.get(&part) {
 					let host = config.host(&machine).await?;
 					let secret = host.decrypt(data.raw.clone()).await?;
 					String::from_utf8(secret).context("secret is not utf8")?
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -27,7 +27,7 @@
 use tracing::{Instrument, error, info, info_span};
 #[cfg(feature = "indicatif")]
 use tracing_indicatif::IndicatifLayer;
-use tracing_subscriber::{EnvFilter, fmt::format::Format, prelude::*};
+use tracing_subscriber::{EnvFilter, prelude::*};
 
 #[derive(Parser)]
 struct Prefetch {}
modifiedcrates/fleet-base/Cargo.tomldiffbeforeafterboth
--- a/crates/fleet-base/Cargo.toml
+++ b/crates/fleet-base/Cargo.toml
@@ -24,6 +24,7 @@
 serde_json = "1.0.140"
 tabled = "0.20.0"
 tempfile.workspace = true
+thiserror.workspace = true
 time = { version = "0.3.41", features = ["parsing"] }
 tokio.workspace = true
 tokio-util = "0.7.15"
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -1,5 +1,5 @@
 use std::{
-	collections::BTreeMap,
+	collections::{BTreeMap, BTreeSet},
 	io::{self, Cursor},
 };
 
@@ -13,6 +13,8 @@
 use serde::{Deserialize, Serialize, de::Error};
 use serde_json::Value;
 
+use crate::secret::{Expectations, RegenerationReason, secret_needs_regeneration};
+
 #[derive(Serialize, Deserialize, Default)]
 #[serde(rename_all = "camelCase")]
 pub struct HostData {
@@ -75,30 +77,21 @@
 	pub shared_secrets: BTreeMap<String, FleetSharedSecret>,
 	#[serde(default)]
 	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
-	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetSecret>>,
+	pub host_secrets: BTreeMap<String, BTreeMap<String, FleetHostSecret>>,
 
 	// extra_name => anything
 	#[serde(default)]
 	#[serde(skip_serializing_if = "BTreeMap::is_empty")]
 	pub extra: BTreeMap<String, Value>,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-#[serde(rename_all = "camelCase")]
-#[must_use]
-pub struct FleetSharedSecret {
-	pub owners: Vec<String>,
-	#[serde(flatten)]
-	pub secret: FleetSecret,
 }
 
 /// Returns None if recipients.is_empty()
-pub fn encrypt_secret_data<'a>(
-	recipients: impl IntoIterator<Item = &'a dyn Recipient>,
+pub fn encrypt_secret_data<'r>(
+	recipients: impl IntoIterator<Item = &'r Box<dyn Recipient>>,
 	data: Vec<u8>,
 ) -> Option<SecretData> {
 	let mut encrypted = vec![];
-	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter())
+	let mut encryptor = age::Encryptor::with_recipients(recipients.into_iter().map(|v| &**v))
 		.ok()?
 		.wrap_output(&mut encrypted)
 		.expect("in memory write");
@@ -118,7 +111,7 @@
 #[derive(Serialize, Deserialize, Clone)]
 #[serde(rename_all = "camelCase")]
 #[must_use]
-pub struct FleetSecret {
+pub struct FleetSecretData {
 	#[serde(default = "Utc::now")]
 	pub created_at: DateTime<Utc>,
 	#[serde(default)]
@@ -132,3 +125,31 @@
 	#[serde(skip_serializing_if = "Value::is_null")]
 	pub generation_data: Value,
 }
+
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+#[must_use]
+pub struct FleetHostSecret {
+	#[serde(default)]
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub managed: Option<bool>,
+	#[serde(flatten)]
+	pub secret: FleetSecretData,
+}
+impl FleetHostSecret {
+	pub fn needs_regeneration(&self, expectations: &Expectations) -> Option<RegenerationReason> {
+		secret_needs_regeneration(&self.secret, &expectations.owners, expectations)
+	}
+}
+
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(rename_all = "camelCase")]
+#[must_use]
+pub struct FleetSharedSecret {
+	#[serde(default)]
+	#[serde(skip_serializing_if = "Option::is_none")]
+	pub managed: Option<bool>,
+	pub owners: BTreeSet<String>,
+	#[serde(flatten)]
+	pub secret: FleetSecretData,
+}
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, FleetSecret, FleetSharedSecret},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: 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}474impl ConfigHost {475	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,476	// assuming getting tags always returns the same value.477	pub async fn tags(&self) -> Result<Vec<String>> {478		if let Some(v) = self.groups.get() {479			return Ok(v.clone());480		}481		let Some(host_config) = &self.host_config else {482			return Ok(vec![]);483		};484		let tags: Vec<String> = nix_go_json!(host_config.tags);485486		let _ = self.groups.set(tags.clone());487488		Ok(tags)489	}490	pub async fn nixos_config(&self) -> Result<Value> {491		if let Some(v) = self.nixos_config.get() {492			return Ok(v.clone());493		}494		let Some(host_config) = &self.host_config else {495			bail!("local host has no nixos_config");496		};497		let nixos_config = nix_go!(host_config.nixos.config);498		assert_warn("nixos config evaluation", &nixos_config).await?;499500		let _ = self.nixos_config.set(nixos_config.clone());501502		Ok(nixos_config)503	}504	pub async fn nixos_unchecked_config(&self) -> Result<Value> {505		if let Some(v) = self.nixos_unchecked_config.get() {506			return Ok(v.clone());507		}508		let Some(host_config) = &self.host_config else {509			bail!("local host has no nixos_config");510		};511		let nixos_config = nix_go!(host_config.nixos_unchecked.config);512513		let _ = self.nixos_unchecked_config.set(nixos_config.clone());514515		Ok(nixos_config)516	}517518	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {519		let nixos = self.nixos_unchecked_config().await?;520		let secrets = nix_go!(nixos.secrets);521		let mut out = Vec::new();522		for name in secrets.list_fields()? {523			let secret = secrets.get_field(&name).context("getting secret")?;524			let is_shared: bool = nix_go_json!(secret.shared);525			if is_shared {526				continue;527			}528			out.push(name);529		}530		Ok(out)531	}532	pub async fn secret_field(&self, name: &str) -> Result<Value> {533		let nixos = self.nixos_unchecked_config().await?;534		Ok(nix_go!(nixos.secrets[{ name }]))535	}536537	/// Packages for this host, resolved with nixpkgs overlays538	pub async fn pkgs(&self) -> Result<Value> {539		if let Some(value) = &self.pkgs_override {540			return Ok(value.clone());541		}542		let Some(host_config) = &self.host_config else {543			bail!("local host has no host_config");544		};545		// TODO: Should nixos.options be cached?546		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))547	}548}549550impl Config {551	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {552		let config = &self.config_field;553		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);554		Ok(tagged)555	}556	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {557		let mut out = BTreeSet::new();558		for owner in owners {559			if let Some(tag) = owner.strip_prefix('@') {560				let hosts = self.tagged_hostnames(tag).await?;561				out.extend(hosts);562			} else {563				out.insert(owner);564			}565		}566		Ok(out)567	}568	pub fn local_host(&self) -> ConfigHost {569		ConfigHost {570			config: self.clone(),571			name: "<virtual localhost>".to_owned(),572			host_config: None,573			nixos_config: OnceCell::new(),574			nixos_unchecked_config: OnceCell::new(),575			groups: {576				let cell = OnceCell::new();577				let _ = cell.set(vec![]);578				cell579			},580			pkgs_override: Some(self.default_pkgs.clone()),581582			local: true,583			session: OnceLock::new(),584			deploy_kind: OnceCell::new(),585			session_destination: OnceCell::new(),586			legacy_ssh_store: OnceCell::new(),587		}588	}589590	pub async fn host(&self, name: &str) -> Result<ConfigHost> {591		let config = &self.config_field;592		let host_config = nix_go!(config.hosts[{ name }]);593594		Ok(ConfigHost {595			config: self.clone(),596			name: name.to_owned(),597			host_config: Some(host_config),598			nixos_config: OnceCell::new(),599			nixos_unchecked_config: OnceCell::new(),600			groups: OnceCell::new(),601			pkgs_override: None,602603			// TODO: Remove with connectivit refactor604			local: self.localhost == name,605			session: OnceLock::new(),606			deploy_kind: OnceCell::new(),607			session_destination: OnceCell::new(),608			legacy_ssh_store: OnceCell::new(),609		})610	}611	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {612		let config = &self.config_field;613		let names = nix_go!(config.hosts).list_fields()?;614		let mut out = vec![];615		for name in names {616			out.push(self.host(&name).await?);617		}618		Ok(out)619	}620	// TODO: Replace usages with .host().nixos_config621	pub async fn system_config(&self, host: &str) -> Result<Value> {622		let fleet_field = &self.config_field;623		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))624	}625626	/// Shared secrets configured in fleet.nix or in flake627	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {628		let config_field = &self.config_field;629		nix_go!(config_field.sharedSecrets).list_fields()630	}631	/// Shared secrets configured in fleet.nix632	pub fn list_shared(&self) -> Vec<String> {633		let data = self.data();634		data.shared_secrets.keys().cloned().collect()635	}636	pub fn has_shared(&self, name: &str) -> bool {637		let data = self.data();638		data.shared_secrets.contains_key(name)639	}640	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {641		let mut data = self.data_mut();642		data.shared_secrets.insert(name.to_owned(), shared);643	}644	pub fn remove_shared(&self, secret: &str) {645		let mut data = self.data_mut();646		data.shared_secrets.remove(secret);647	}648649	pub fn list_secrets(&self, host: &str) -> Vec<String> {650		let data = self.data();651		let Some(secrets) = data.host_secrets.get(host) else {652			return Vec::new();653		};654		secrets.keys().cloned().collect()655	}656657	pub fn has_secret(&self, host: &str, secret: &str) -> bool {658		let data = self.data();659		let Some(host_secrets) = data.host_secrets.get(host) else {660			return false;661		};662		host_secrets.contains_key(secret)663	}664	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {665		let mut data = self.data_mut();666		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();667		host_secrets.insert(secret, value);668	}669670	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {671		let data = self.data();672		let Some(host_secrets) = data.host_secrets.get(host) else {673			bail!("no secrets for machine {host}");674		};675		let Some(secret) = host_secrets.get(secret) else {676			bail!("machine {host} has no secret {secret}");677		};678		Ok(secret.clone())679	}680	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {681		let data = self.data();682		let Some(secret) = data.shared_secrets.get(secret) else {683			bail!("no shared secret {secret}");684		};685		Ok(secret.clone())686	}687	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {688		let config_field = &self.config_field;689		Ok(nix_go_json!(690			config_field.sharedSecrets[{ secret }].expectedOwners691		))692	}693694	// TODO: Should this be something modifiable from other processes?695	// E.g terraform provider might want to update FleetData (e.g secrets),696	// and current implementation assumes only one process holds current fleet.nix697	// Given that it is no longer needs to be a file for nix evaluation,698	// maybe it can be a .nix file for persistence, but accessible only699	// thru some shared state controller? Might it be stored in terraform700	// state provider?701	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {702		self.data.lock().unwrap()703	}704	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {705		self.data.lock().unwrap()706	}707	pub fn save(&self) -> Result<()> {708		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.")?;709		let data = nixlike::serialize(&self.data() as &FleetData)?;710		tempfile.write_all(711			format!(712				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"713			)714			.as_bytes(),715		)?;716		let mut fleet_data_path = self.directory.clone();717		fleet_data_path.push("fleet.nix");718		tempfile.persist(fleet_data_path)?;719		Ok(())720	}721}
modifiedcrates/fleet-base/src/keys.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/keys.rs
+++ b/crates/fleet-base/src/keys.rs
@@ -39,12 +39,14 @@
 		}
 	}
 	/// Insecure, requires root
-	pub async fn recipient(&self, host: &str) -> anyhow::Result<impl Recipient + use<>> {
+	pub async fn recipient(&self, host: &str) -> 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))
+		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<impl Recipient + use<>>> {
+	pub async fn recipients(&self, hosts: Vec<String>) -> Result<Vec<Box<dyn Recipient>>> {
 		let hosts = self.expand_owner_set(hosts).await?;
 		futures::stream::iter(hosts.iter())
 			.then(|m| self.recipient(m.as_ref()))
modifiedcrates/fleet-base/src/lib.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/lib.rs
+++ b/crates/fleet-base/src/lib.rs
@@ -4,3 +4,4 @@
 pub mod host;
 mod keys;
 pub mod opts;
+pub mod secret;
addedcrates/fleet-base/src/secret.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/fleet-base/src/secret.rs
@@ -0,0 +1,136 @@
+use std::collections::BTreeSet;
+
+use anyhow::Result;
+use chrono::{DateTime, Utc};
+use nix_eval::{Value, nix_go, nix_go_json};
+
+use crate::fleetdata::FleetSecretData;
+
+#[derive(Debug)]
+pub struct Expectations {
+	pub owners: BTreeSet<String>,
+	pub generation_data: serde_json::Value,
+	pub public_parts: BTreeSet<String>,
+	pub private_parts: BTreeSet<String>,
+}
+
+pub struct HostSecretDefinition(pub(crate) String, pub(crate) Value);
+impl HostSecretDefinition {
+	pub fn is_managed(&self) -> Result<bool> {
+		let value = &self.1;
+		Ok(!nix_go!(value.generator).is_null())
+	}
+	pub fn expectations(&self) -> Result<Expectations> {
+		let value = &self.1;
+		Ok(Expectations {
+			owners: BTreeSet::from([self.0.clone()]),
+			generation_data: nix_go_json!(value.expectedGenerationData),
+			public_parts: nix_go_json!(value.expectedPublicParts),
+			private_parts: nix_go_json!(value.expectedPrivateParts),
+		})
+	}
+	pub fn inner(&self) -> Value {
+		self.1.clone()
+	}
+}
+
+pub struct SharedSecretDefinition(pub(crate) Value);
+impl SharedSecretDefinition {
+	pub fn is_managed(&self) -> Result<bool> {
+		let value = &self.0;
+		Ok(!nix_go!(value.generator).is_null())
+	}
+	pub fn expectations(&self) -> Result<Expectations> {
+		let value = &self.0;
+		Ok(Expectations {
+			owners: nix_go_json!(value.expectedOwners),
+			generation_data: nix_go_json!(value.expectedGenerationData),
+			public_parts: nix_go_json!(value.expectedPublicParts),
+			private_parts: nix_go_json!(value.expectedPrivateParts),
+		})
+	}
+	pub fn inner(&self) -> Value {
+		self.0.clone()
+	}
+}
+
+#[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>),
+}
+
+pub fn secret_needs_regeneration(
+	secret: &FleetSecretData,
+	owners: &BTreeSet<String>,
+	expectations: &Expectations,
+) -> Option<RegenerationReason> {
+	if !owners.is_empty() {
+		let added: BTreeSet<String> = expectations.owners.difference(owners).cloned().collect();
+		if !added.is_empty() {
+			return Some(RegenerationReason::OwnersAdded(added));
+		}
+
+		let removed: BTreeSet<String> = owners.difference(&expectations.owners).cloned().collect();
+		if !removed.is_empty() {
+			return Some(RegenerationReason::OwnersRemoved(removed));
+		}
+	}
+
+	if secret.generation_data != expectations.generation_data {
+		return Some(RegenerationReason::GenerationData {
+			expected: expectations.generation_data.clone(),
+			found: secret.generation_data.clone(),
+		});
+	}
+
+	if !expectations.public_parts.is_empty() || !expectations.private_parts.is_empty() {
+		let expected: BTreeSet<String> = expectations
+			.public_parts
+			.union(&expectations.private_parts)
+			.cloned()
+			.collect();
+		let found: BTreeSet<String> = secret.parts.keys().cloned().collect();
+
+		if found != expected {
+			return Some(RegenerationReason::PartList { expected, found });
+		}
+
+		for (name, value) in secret.parts.iter() {
+			if value.raw.encrypted {
+				if !expectations.private_parts.contains(name) {
+					return Some(RegenerationReason::ExpectedPrivate(name.clone()));
+				}
+			} else if !expectations.public_parts.contains(name) {
+				return Some(RegenerationReason::ExpectedPublic(name.clone()));
+			}
+		}
+	}
+
+	if let Some(expiration) = secret.expires_at {
+		// TODO: Leeway?
+		if expiration < Utc::now() {
+			return Some(RegenerationReason::Expired(expiration));
+		}
+	}
+
+	None
+}
modifiedcrates/nix-eval/src/lib.rsdiffbeforeafterboth
--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -729,10 +729,13 @@
 
 		with_default_context(|c, es| unsafe { get_list_byidx(c, self.0, es, v as u32) }).map(Self)
 	}
-	pub fn attrs_update(self, other: Value/*, ignore_errors: bool*/) -> Result<Self> {
+	pub fn attrs_update(self, other: Value /*, ignore_errors: bool*/) -> Result<Self> {
 		let attrs_update_fn = Self::eval("a: b: a // b")?;
 
-		attrs_update_fn.call(self)?.call(other).context("attrs update")
+		attrs_update_fn
+			.call(self)?
+			.call(other)
+			.context("attrs update")
 	}
 	pub fn get_field(&self, name: impl AsFieldName) -> Result<Self> {
 		if !matches!(self.type_of(), NixType::Attrs) {
@@ -840,6 +843,9 @@
 	pub fn is_function(&self) -> bool {
 		self.functor_kind().is_some()
 	}
+	pub fn is_null(&self) -> bool {
+		matches!(self.type_of(), NixType::Null)
+	}
 }
 
 impl From<String> for Value {
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -181,7 +181,7 @@
                 inputs'.nix.packages.nix-fetchers-c
                 inputs'.nix.packages.nix-store-c
 
-                (rage.overrideAttrs {cargoFeatures = ["plugin"];})
+                (rage.overrideAttrs { cargoFeatures = [ "plugin" ]; })
               ];
               environment.PROTOC = "${pkgs.protobuf}/bin/protoc";
             };
modifiedmodules/extras/tf.nixdiffbeforeafterboth
--- a/modules/extras/tf.nix
+++ b/modules/extras/tf.nix
@@ -38,17 +38,19 @@
       # will be somehow processed by fleet tf.
       sensitive = true;
     };
-    fleetConfigurations.default = {config, ...}: {
-      options.data = mkDataOption {
-        # host => hostData
-        options.extra.terraformHosts = mkOption {
-          default = { };
-          type = attrsOf (attrsOf unspecified);
-          description = "Hosts data provided by fleet tf";
+    fleetConfigurations.default =
+      { config, ... }:
+      {
+        options.data = mkDataOption {
+          # host => hostData
+          options.extra.terraformHosts = mkOption {
+            default = { };
+            type = attrsOf (attrsOf unspecified);
+            description = "Hosts data provided by fleet tf";
+          };
         };
+        config.hosts = config.data.extra.terraformHosts;
       };
-      config.hosts = config.data.extra.terraformHosts;
-    };
 
     perSystem.imports = [ ./tf-bootstrap.nix ];
   };
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
--- a/modules/nixos/secrets.nix
+++ b/modules/nixos/secrets.nix
@@ -6,11 +6,11 @@
   ...
 }:
 let
-  inherit (builtins) hashString;
+  inherit (builtins) hashString elemAt length toJSON filter;
   inherit (lib.stringsWithDeps) stringAfter;
   inherit (lib.options) mkOption literalExpression;
   inherit (lib.lists) optional;
-  inherit (lib.attrsets) mapAttrs;
+  inherit (lib.attrsets) mapAttrs mapAttrsToList;
   inherit (lib.modules) mkIf;
   inherit (lib.types)
     submodule
@@ -22,10 +22,29 @@
     uniq
     functionTo
     package
+    listOf
     ;
   inherit (fleetLib.strings) decodeRawSecret;
 
   sysConfig = config;
+  secretPartDataType = submodule {
+    options = {
+      raw = mkOption {
+        type = str;
+        internal = true;
+        description = "Encoded & Encrypted secret part data, passed from fleet.nix";
+      };
+    };
+  };
+  secretDataType = submodule {
+    freeformType = lazyAttrsOf secretPartDataType;
+    options = {
+      shared = mkOption {
+        description = "Is this secret owned by this machine, or propagated from shared secrets";
+        default = false;
+      };
+    };
+  };
   secretPartType =
     secretName:
     submodule (
@@ -35,11 +54,6 @@
       in
       {
         options = {
-          raw = mkOption {
-            type = str;
-            internal = true;
-            description = "Encoded & Encrypted secret part data, passed from fleet.nix";
-          };
           hash = mkOption {
             type = str;
             description = "Hash of secret in encoded format";
@@ -50,34 +64,50 @@
           };
           stablePath = mkOption {
             type = str;
-            description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";
+            description = "Path to secret part, stable path (users are expected to watch for file changes/re-read secret on demand)";
           };
           data = mkOption {
             type = str;
             description = "Secret public data (only available for plaintext)";
           };
         };
-        config = {
-          hash = hashString "sha1" config.raw;
-          data = decodeRawSecret config.raw;
-          path = "/run/secrets/${secretName}/${config.hash}-${partName}";
-          stablePath = "/run/secrets/${secretName}/${partName}";
-        };
+        config =
+          let
+            raw = sysConfig.data.secrets.${secretName}.${partName}.raw;
+          in
+          {
+            hash = hashString "sha1" raw;
+            data = decodeRawSecret raw;
+            path = "/run/secrets/${secretName}/${config.hash}-${partName}";
+            stablePath = "/run/secrets/${secretName}/${partName}";
+          };
       }
     );
   secretType = submodule (
-    { config, ... }:
+    {
+      config,
+      loc,
+      options,
+      ...
+    }:
     let
-      secretName = config._module.args.name;
+      secretName =
+        # Due to config definition for freeformType, we can't just use _module.args due to infinite recursion, instead
+        # extract the secret name the ugly way...
+        let
+          saLoc = options._module.specialArgs.loc;
+          comp = elemAt saLoc;
+        in
+        assert
+          (length saLoc == 2 ||
+          length saLoc == 4 &&
+          comp 0 == "secrets" && comp 2 == "_module" && comp 3 == "specialArgs") ||
+          throw "Unexpected module structure ${toJSON saLoc}";
+        if length saLoc == 2 then "documentation generator stub" else comp 1;
     in
     {
       freeformType = lazyAttrsOf (secretPartType secretName);
       options = {
-        shared = mkOption {
-          description = "Is this secret owned by this machine, or propagated from shared secrets";
-          default = false;
-        };
-
         generator = mkOption {
           type = uniq (nullOr (functionTo package));
           description = "Derivation to evaluate for secret generation";
@@ -104,18 +134,30 @@
           description = "Data that gets embedded into secret part";
           default = null;
         };
+        expectedPrivateParts = mkOption {
+          type = listOf str;
+          default = [ ];
+          description = "List of parts that are expected to be encrypted";
+        };
+        expectedPublicParts = mkOption {
+          type = listOf str;
+          default = [ ];
+          description = "List of parts that are expected to be public";
+        };
       };
+      config = mapAttrs (_: _: { }) (removeAttrs (sysConfig.data.secrets.${secretName} or {}) [ "shared" ]);
     }
   );
-  processPart = part: {
-    inherit (part) raw path stablePath;
+  processPart = secretName: partName: part: {
+    inherit (part) path stablePath;
+    raw = config.data.secrets.${secretName}.${partName}.raw;
   };
   processSecret =
-    secret:
+    secretName: secret:
     {
       inherit (secret) group mode owner;
     }
-    // (mapAttrs (_: processPart) (
+    // (mapAttrs (processPart secretName) (
       removeAttrs secret [
         "shared"
         "generator"
@@ -123,11 +165,14 @@
         "group"
         "owner"
         "expectedGenerationData"
+        "expectedPrivateParts"
+        "expectedPublicParts"
       ]
     ));
+  secretsData = (mapAttrs (processSecret) config.secrets);
   secretsFile = pkgs.writeTextFile {
     name = "secrets.json";
-    text = builtins.toJSON (mapAttrs (_: processSecret) config.secrets);
+    text = toJSON secretsData;
   };
   useSysusers =
     (config.systemd ? sysusers && config.systemd.sysusers.enable)
@@ -135,15 +180,38 @@
 in
 {
   options = {
+    data.secrets = mkOption {
+      type = attrsOf secretDataType;
+      default = { };
+      description = "Host-local secret data";
+    };
     secrets = mkOption {
       type = attrsOf secretType;
       default = { };
       description = "Host-local secrets";
     };
+    system.secretsData = mkOption {
+      type = unspecified;
+      default = {};
+      description = "secrets.json contents";
+    };
   };
   config = {
+    system = {inherit secretsData;};
     environment.systemPackages = [ pkgs.fleet-install-secrets ];
 
+    warnings = filter (v: v!=null) (mapAttrsToList (
+      name: secret:
+      if
+        secret.expectedPrivateParts == [ ]
+        && secret.expectedPublicParts == [ ]
+        && !(config.data.secrets.${name} or { shared = false; }).shared
+      then
+        "Secret ${name} has no expected parts defined, this is deprecated for better visibility"
+      else
+        null
+    ) config.secrets);
+
     systemd.services.fleet-install-secrets = mkIf useSysusers {
       wantedBy = [ "sysinit.target" ];
       after = [ "systemd-sysusers.service" ];
modifiedmodules/secrets-data.nixdiffbeforeafterboth
--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -151,8 +151,9 @@
           toJSON (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners
         }. Run fleet secrets regenerate to fix";
       }) config.sharedSecrets)
+
       ++ (mapAttrsToList (name: secret: {
-        # TODO: Same aassertion should be in host secrets
+        # TODO: Same assertion should be in host secrets
         assertion =
           (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
           == secret.expectedGenerationData;
@@ -160,6 +161,5 @@
           toJSON (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
         }. Run fleet secrets regenerate to fix";
       }) config.sharedSecrets);
-    sharedSecrets = mapAttrs (_: _: { }) config.data.sharedSecrets;
   };
 }
modifiedmodules/secrets.nixdiffbeforeafterboth
--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -69,6 +69,16 @@
           description = "Contextual metadata embedded within the secret part value";
           default = null;
         };
+        expectedPrivateParts = mkOption {
+          type = listOf str;
+          default = [ ];
+          description = "List of parts that are expected to be encrypted";
+        };
+        expectedPublicParts = mkOption {
+          type = listOf str;
+          default = [ ];
+          description = "List of parts that are expected to be public";
+        };
       };
     };
 in
@@ -81,16 +91,25 @@
     };
   };
   config = {
-    hosts = mapAttrs (_: secretMap: {
-      nixos.secrets = mapAttrs (
-        _: s:
-        removeAttrs s [
-          "createdAt"
-          "expiresAt"
-          "generationData"
-        ]
-      ) secretMap;
-    }) config.data.hostSecrets;
+    hosts = mapAttrs (
+      _: secretMap:
+      let
+        partsOf =
+          s:
+          removeAttrs s [
+            "createdAt"
+            "expiresAt"
+            "generationData"
+          ];
+
+      in
+      {
+        nixos.data.secrets = mapAttrs (_: s: partsOf s) secretMap;
+        # nixos.secrets = mapAttrs (
+        #   _: s: mapAttrs (_: _: {}) (partsOf s)
+        # ) secretMap;
+      }
+    ) config.data.hostSecrets;
     nixpkgs.overlays = [
       (final: prev: {
         mkSecretGenerators =