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
after · 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, FleetHostSecret, FleetSharedSecret},26	secret::{HostSecretDefinition, SharedSecretDefinition},27};2829pub struct FleetConfigInternals {30	/// Fleet project directory, containing fleet.nix file.31	pub directory: PathBuf,32	/// builtins.currentSystem33	pub local_system: String,34	pub data: Mutex<FleetData>,35	pub nix_args: Vec<OsString>,36	/// fleet_config.config37	pub config_field: Value,38	/// flake.output39	pub flake_outputs: Value,40	// TODO: Remove with connectivity refactor41	pub localhost: String,4243	/// import nixpkgs {system = local};44	pub default_pkgs: Value,45	/// inputs.nixpkgs46	pub nixpkgs: Value,47}4849// TODO: Make field not pub50#[derive(Clone)]51pub struct Config(pub Arc<FleetConfigInternals>);5253impl Deref for Config {54	type Target = FleetConfigInternals;5556	fn deref(&self) -> &Self::Target {57		&self.058	}59}6061#[derive(Clone, Copy, Debug)]62pub enum EscalationStrategy {63	Sudo,64	Run0,65	Su,66}6768#[derive(Clone, PartialEq, Copy, Debug)]69pub enum DeployKind {70	/// NixOS => NixOS managed by fleet71	UpgradeToFleet,72	/// NixOS managed by fleet => NixOS managed by fleet73	Fleet,74	/// Remote host has /mnt, /mnt/boot mounted,75	/// generated config is added to fleet configuration.76	NixosInstall,77	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),78	/// generated config is added to fleet configuration,79	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.80	NixosLustrate,81}8283impl FromStr for DeployKind {84	type Err = anyhow::Error;85	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {86		match s {87			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),88			"fleet" => Ok(Self::Fleet),89			"nixos-install" => Ok(Self::NixosInstall),90			"nixos-lustrate" => Ok(Self::NixosLustrate),91			v => bail!(92				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""93			),94		}95	}96}97pub struct ConfigHost {98	config: Config,99	pub name: String,100	groups: OnceCell<Vec<String>>,101102	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it103	deploy_kind: OnceCell<DeployKind>,104	session_destination: OnceCell<String>,105	legacy_ssh_store: OnceCell<bool>,106107	pub host_config: Option<Value>,108	pub nixos_config: OnceCell<Value>,109	pub nixos_unchecked_config: OnceCell<Value>,110	pub pkgs_override: Option<Value>,111112	// TODO: Move command helpers away with connectivity refactor113	pub local: bool,114	pub session: OnceLock<Arc<openssh::Session>>,115}116117#[derive(Debug, Clone, Copy)]118pub enum GenerationStorage {119	Deployer,120	Machine,121	Pusher,122}123impl GenerationStorage {124	fn prefix(&self) -> &'static str {125		match self {126			GenerationStorage::Deployer => "deployer.",127			GenerationStorage::Machine => "",128			GenerationStorage::Pusher => "pusher.",129		}130	}131}132133#[derive(Tabled, Debug)]134pub struct Generation {135	#[tabled(rename = "ID", format("{}", self.rollback_id()))]136	pub id: u32,137	#[tabled(rename = "Current")]138	pub current: bool,139	#[tabled(rename = "Created at")]140	pub datetime: UtcDateTime,141	#[tabled(format = "{:?}")]142	pub store_path: PathBuf,143	#[tabled(skip)]144	pub location: GenerationStorage,145}146impl Generation {147	pub fn rollback_id(&self) -> String {148		format!("{}{}", self.location.prefix(), self.id)149	}150}151152fn parse_generation_line(g: &str) -> Option<Generation> {153	let mut parts = g.split_whitespace();154	let id = parts.next()?;155	let id: u32 = id.parse().ok()?;156	let date = parts.next()?;157	let time = parts.next()?;158	let current = if let Some(current) = parts.next() {159		if current == "(current)" {160			Some(true)161		} else {162			None163		}164	} else {165		Some(false)166	};167	let current = current?;168	if parts.next().is_some() {169		warn!("unexpected text after generation: {g}");170	}171172	let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")173		.expect("valid format");174	let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;175176	Some(Generation {177		id,178		current,179		datetime,180		store_path: PathBuf::new(),181		location: GenerationStorage::Machine,182	})183}184// TODO: Move command helpers away with connectivity refactor185impl ConfigHost {186	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {187		let mut cmd = self.cmd("nix-env").await?;188		cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))189			.arg("--list-generations")190			.env("TZ", "UTC");191		// Sudo is required because --list-generations tries to acquire profile lock192		let data = cmd.sudo().run_string().await?;193		let mut generations = data194			.split('\n')195			.map(|e| e.trim())196			.filter(|&l| !l.is_empty())197			.filter_map(|g| {198				let generation = parse_generation_line(g);199				if generation.is_none() {200					warn!("bad generation: {g}");201				};202				generation203			})204			.collect::<Vec<_>>();205		for ele in generations.iter_mut() {206			let mut cmd = self.cmd("readlink").await?;207			cmd.arg("--")208				.arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));209			let path = cmd.run_string().await?;210			ele.store_path = PathBuf::from(path.trim_end_matches("\n"));211		}212213		Ok(generations)214	}215216	pub fn set_session_destination(&self, dest: String) {217		self.session_destination218			.set(dest)219			.expect("session destination is already set")220	}221	pub fn set_deploy_kind(&self, kind: DeployKind) {222		self.deploy_kind223			.set(kind)224			.expect("deploy kind is already set");225	}226	pub fn set_legacy_ssh_store(&self, legacy: bool) {227		self.legacy_ssh_store228			.set(legacy)229			.expect("legacy ssh store is already set")230	}231	pub async fn deploy_kind(&self) -> Result<DeployKind> {232		if let Some(kind) = self.deploy_kind.get() {233			return Ok(*kind);234		}235		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {236			Ok(v) => v,237			Err(e) => {238				bail!("failed to query remote system kind: {e}");239			}240		};241		if !is_fleet_managed {242			bail!(243				"{}",244				indoc::indoc! {"245				host is not marked as managed by fleet246				if you're not trying to lustrate/install system from scratch,247				you should either248					1. manually create /etc/FLEET_HOST file on the target host,249					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet250					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos251			"}252			);253		}254		// TOCTOU is possible255		let _ = self.deploy_kind.set(DeployKind::Fleet);256		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))257	}258	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {259		// Prefer sudo, as run0 has some gotchas with polkit260		// and too many repeating prompts.261		if (self.find_in_path("sudo").await).is_ok() {262			return Ok(EscalationStrategy::Sudo);263		}264		if (self.find_in_path("run0").await).is_ok() {265			return Ok(EscalationStrategy::Run0);266		}267		Ok(EscalationStrategy::Su)268	}269	async fn open_session(&self) -> Result<Arc<openssh::Session>> {270		assert!(!self.local, "do not open ssh connection to local session");271		// FIXME: TOCTOU272		if let Some(session) = &self.session.get() {273			return Ok((*session).clone());274		};275		let mut session = SessionBuilder::default();276		session.control_persist(ControlPersist::ClosedAfterInitialConnection);277278		let dest = self.session_destination.get().unwrap_or(&self.name);279		let session = session280			.connect(&dest)281			.await282			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;283		let session = Arc::new(session);284		self.session.set(session.clone()).expect("TOCTOU happened");285		Ok(session)286	}287	pub async fn mktemp_dir(&self) -> Result<String> {288		let mut cmd = self.cmd("mktemp").await?;289		cmd.arg("-d");290		let path = cmd.run_string().await?;291		Ok(path.trim_end().to_owned())292	}293	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {294		let mut cmd = self.cmd("sh").await?;295		cmd.arg("-c")296			.arg("test -e \"$1\" && echo true || echo false")297			.arg("_")298			.arg(path);299		cmd.run_value().await300	}301	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {302		let mut cmd = self.cmd("cat").await?;303		cmd.arg(path);304		cmd.run_bytes().await305	}306	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {307		let mut cmd = self.cmd("cat").await?;308		cmd.arg(path);309		cmd.run_string().await310	}311	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {312		let mut cmd = self.cmd("ls").await?;313		cmd.arg(path);314		let out = cmd.run_string().await?;315		let mut lines = out.split('\n');316		if let Some(last) = lines.next_back() {317			ensure!(last.is_empty(), "output of ls should end with newline");318		}319		Ok(lines.map(ToOwned::to_owned).collect())320	}321	#[allow(dead_code)]322	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {323		let text = self.read_file_text(path).await?;324		Ok(serde_json::from_str(&text)?)325	}326	pub async fn read_env(&self, env: &str) -> Result<String> {327		let mut cmd = self.cmd("printenv").await?;328		cmd.arg(env);329		cmd.run_string().await330	}331	pub async fn find_in_path(&self, command: &str) -> Result<String> {332		// // `which` is not a part of coreutils, and it might not exist on machine.333		// let path = self.read_env("PATH").await?;334		// // Assuming delimiter is :, we don't work with windows host, this check will be much335		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)336		// for ele in path.split(':') {337		// 	let test_path = format!("{ele}/{cmd}");338		// 	test -x etc339		// }340		// let mut cmd = self.cmd("printenv").await?;341		// cmd.arg(env);342		// Ok(cmd.run_string().await?)343		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.344		let mut cmd = self345			.cmd_escalation(346				// Not used347				EscalationStrategy::Su,348				"which",349			)350			.await?;351		cmd.arg(command);352		cmd.run_string().await353	}354	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>355	where356		<D as FromStr>::Err: Display,357	{358		let text = self.read_file_text(path).await?;359		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))360	}361	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {362		self.cmd_escalation(self.escalation_strategy().await?, cmd)363			.await364	}365	pub async fn cmd_escalation(366		&self,367		escalation: EscalationStrategy,368		cmd: impl AsRef<OsStr>,369	) -> Result<MyCommand> {370		if self.local {371			Ok(MyCommand::new(escalation, cmd))372		} else {373			let session = self.open_session().await?;374			Ok(MyCommand::new_on(escalation, cmd, session))375		}376	}377	pub async fn nix_cmd(&self) -> Result<MyCommand> {378		let mut nix = self.cmd("nix").await?;379		nix.args([380			"--extra-experimental-features",381			"nix-command",382			"--extra-experimental-features",383			"flakes",384		]);385		Ok(nix)386	}387388	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {389		ensure!(data.encrypted, "secret is not encrypted");390		let mut cmd = self.cmd("fleet-install-secrets").await?;391		cmd.arg("decrypt").eqarg("--secret", data.to_string());392		let encoded = cmd393			.sudo()394			.run_string()395			.await396			.context("failed to call remote host for decrypt")?;397		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;398		ensure!(!data.encrypted, "secret came out encrypted");399		Ok(data.data)400	}401	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {402		ensure!(data.encrypted, "secret is not encrypted");403		let mut cmd = self.cmd("fleet-install-secrets").await?;404		cmd.arg("reencrypt").eqarg("--secret", data.to_string());405		for target in targets {406			let key = self.config.key(&target).await?;407			cmd.eqarg("--targets", key);408		}409		let encoded = cmd410			.sudo()411			.run_string()412			.await413			.context("failed to call remote host for decrypt")?;414		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;415		ensure!(data.encrypted, "secret came out not encrypted");416		Ok(data)417	}418	/// Returns path for futureproofing, as path might change i.e on conversion to CA419	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {420		if self.local {421			// Path is located locally, thus already trusted.422			return Ok(path.to_owned());423		}424		let mut nix = MyCommand::new(425			// Not used426			EscalationStrategy::Su,427			"nix",428		);429		nix.arg("copy").arg("--substitute-on-destination");430431		let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {432			"ssh"433		} else {434			"ssh-ng"435		};436437		match self.deploy_kind().await? {438			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {439				nix.comparg("--to", format!("{proto}://{}", self.name));440			}441			DeployKind::NixosInstall => {442				nix443					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon444					.arg("--no-check-sigs")445					.comparg(446						"--to",447						format!("{proto}://root@{}?remote-store=/mnt", self.name),448					);449			}450		}451		nix.arg(path);452		nix.run_nix().await.context("nix copy")?;453		Ok(path.to_owned())454	}455	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {456		let mut cmd = self.cmd("systemctl").await?;457		cmd.arg("stop").arg(name);458		cmd.sudo().run().await459	}460	pub async fn systemctl_start(&self, name: &str) -> Result<()> {461		let mut cmd = self.cmd("systemctl").await?;462		cmd.arg("start").arg(name);463		cmd.sudo().run().await464	}465466	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {467		let mut cmd = self.cmd("rm").await?;468		cmd.arg("-f").arg(path);469		if sudo {470			cmd = cmd.sudo()471		}472		cmd.run().await473	}474}475impl ConfigHost {476	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,477	// assuming getting tags always returns the same value.478	pub async fn tags(&self) -> Result<Vec<String>> {479		if let Some(v) = self.groups.get() {480			return Ok(v.clone());481		}482		let Some(host_config) = &self.host_config else {483			return Ok(vec![]);484		};485		let tags: Vec<String> = nix_go_json!(host_config.tags);486487		let _ = self.groups.set(tags.clone());488489		Ok(tags)490	}491	pub async fn nixos_config(&self) -> Result<Value> {492		if let Some(v) = self.nixos_config.get() {493			return Ok(v.clone());494		}495		let Some(host_config) = &self.host_config else {496			bail!("local host has no nixos_config");497		};498		let nixos_config = nix_go!(host_config.nixos.config);499		assert_warn("nixos config evaluation", &nixos_config).await?;500501		let _ = self.nixos_config.set(nixos_config.clone());502503		Ok(nixos_config)504	}505	pub fn nixos_unchecked_config(&self) -> Result<Value> {506		if let Some(v) = self.nixos_unchecked_config.get() {507			return Ok(v.clone());508		}509		let Some(host_config) = &self.host_config else {510			bail!("local host has no nixos_config");511		};512		let nixos_config = nix_go!(host_config.nixos_unchecked.config);513514		let _ = self.nixos_unchecked_config.set(nixos_config.clone());515516		Ok(nixos_config)517	}518519	pub fn list_defined_secrets(&self) -> Result<Vec<String>> {520		let nixos = self.nixos_unchecked_config()?;521		let secrets = nix_go!(nixos.secrets);522		secrets.list_fields()523	}524	pub fn secret_definition(&self, name: &str) -> Result<HostSecretDefinition> {525		let nixos = self.nixos_unchecked_config()?;526		Ok(HostSecretDefinition(527			self.name.clone(),528			nix_go!(nixos.secrets[{ name }]),529		))530	}531532	/// Packages for this host, resolved with nixpkgs overlays533	pub async fn pkgs(&self) -> Result<Value> {534		if let Some(value) = &self.pkgs_override {535			return Ok(value.clone());536		}537		let Some(host_config) = &self.host_config else {538			bail!("local host has no host_config");539		};540		// TODO: Should nixos.options be cached?541		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))542	}543}544545impl Config {546	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {547		let config = &self.config_field;548		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);549		Ok(tagged)550	}551	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {552		let mut out = BTreeSet::new();553		for owner in owners {554			if let Some(tag) = owner.strip_prefix('@') {555				let hosts = self.tagged_hostnames(tag).await?;556				out.extend(hosts);557			} else {558				out.insert(owner);559			}560		}561		Ok(out)562	}563	pub fn local_host(&self) -> ConfigHost {564		ConfigHost {565			config: self.clone(),566			name: "<virtual localhost>".to_owned(),567			host_config: None,568			nixos_config: OnceCell::new(),569			nixos_unchecked_config: OnceCell::new(),570			groups: {571				let cell = OnceCell::new();572				let _ = cell.set(vec![]);573				cell574			},575			pkgs_override: Some(self.default_pkgs.clone()),576577			local: true,578			session: OnceLock::new(),579			deploy_kind: OnceCell::new(),580			session_destination: OnceCell::new(),581			legacy_ssh_store: OnceCell::new(),582		}583	}584585	pub async fn host(&self, name: &str) -> Result<ConfigHost> {586		let config = &self.config_field;587		let host_config = nix_go!(config.hosts[{ name }]);588589		Ok(ConfigHost {590			config: self.clone(),591			name: name.to_owned(),592			host_config: Some(host_config),593			nixos_config: OnceCell::new(),594			nixos_unchecked_config: OnceCell::new(),595			groups: OnceCell::new(),596			pkgs_override: None,597598			// TODO: Remove with connectivit refactor599			local: self.localhost == name,600			session: OnceLock::new(),601			deploy_kind: OnceCell::new(),602			session_destination: OnceCell::new(),603			legacy_ssh_store: OnceCell::new(),604		})605	}606	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {607		let config = &self.config_field;608		let names = nix_go!(config.hosts).list_fields()?;609		let mut out = vec![];610		for name in names {611			out.push(self.host(&name).await?);612		}613		Ok(out)614	}615	// TODO: Replace usages with .host().nixos_config616	pub async fn system_config(&self, host: &str) -> Result<Value> {617		let fleet_field = &self.config_field;618		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))619	}620621	/// Shared secrets configured in fleet.nix or in flake622	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {623		let config_field = &self.config_field;624		nix_go!(config_field.sharedSecrets).list_fields()625	}626	/// Shared secrets configured in fleet.nix627	pub fn list_shared(&self) -> Vec<String> {628		let data = self.data();629		data.shared_secrets.keys().cloned().collect()630	}631	pub fn has_shared(&self, name: &str) -> bool {632		let data = self.data();633		data.shared_secrets.contains_key(name)634	}635	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {636		let mut data = self.data_mut();637		data.shared_secrets.insert(name.to_owned(), shared);638	}639	pub fn remove_shared(&self, secret: &str) {640		let mut data = self.data_mut();641		data.shared_secrets.remove(secret);642	}643644	pub fn list_secrets(&self, host: &str) -> Vec<String> {645		let data = self.data();646		let mut out = data647			.host_secrets648			.get(host)649			.map(|s| s.keys().cloned().collect::<Vec<String>>())650			.unwrap_or_default();651652		for (name, shared) in data.shared_secrets.iter() {653			if shared.owners.contains(host) {654				out.push(name.clone());655			}656		}657658		out659	}660661	pub fn has_secret(&self, host: &str, secret: &str) -> bool {662		let data = self.data();663		let Some(host_secrets) = data.host_secrets.get(host) else {664			return false;665		};666		host_secrets.contains_key(secret)667	}668	pub fn insert_secret(&self, host: &str, secret: String, value: FleetHostSecret) {669		let mut data = self.data_mut();670		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();671		host_secrets.insert(secret, value);672	}673	pub fn remove_secret(&self, host: &str, secret: &str) {674		let mut data = self.data_mut();675		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();676		host_secrets.remove(secret);677	}678679	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetHostSecret> {680		let data = self.data();681		if let Some(host_secrets) = data.host_secrets.get(host) {682			if let Some(secret) = host_secrets.get(secret) {683				return Ok(secret.clone());684			}685		};686		let Some(shared) = data.shared_secrets.get(secret) else {687			bail!("machine {host} has no secret {secret}");688		};689		if !shared.owners.contains(host) {690			bail!("shared secret {secret} is not owned by {host}");691		};692		Ok(FleetHostSecret {693			managed: shared.managed,694			secret: shared.secret.clone(),695		})696	}697	pub fn shared_secret(&self, secret: &str) -> Result<Option<FleetSharedSecret>> {698		let data = self.data();699		Ok(data.shared_secrets.get(secret).cloned())700	}701	pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {702		let config_field = &self.config_field;703		Ok(SharedSecretDefinition(nix_go!(704			config_field.sharedSecrets[{ secret }]705		)))706	}707708	// TODO: Should this be something modifiable from other processes?709	// E.g terraform provider might want to update FleetData (e.g secrets),710	// and current implementation assumes only one process holds current fleet.nix711	// Given that it is no longer needs to be a file for nix evaluation,712	// maybe it can be a .nix file for persistence, but accessible only713	// thru some shared state controller? Might it be stored in terraform714	// state provider?715	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {716		self.data.lock().unwrap()717	}718	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {719		self.data.lock().unwrap()720	}721	pub fn save(&self) -> Result<()> {722		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.")?;723		let data = nixlike::serialize(&self.data() as &FleetData)?;724		tempfile.write_all(725			format!(726				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"727			)728			.as_bytes(),729		)?;730		let mut fleet_data_path = self.directory.clone();731		fleet_data_path.push("fleet.nix");732		tempfile.persist(fleet_data_path)?;733		Ok(())734	}735}
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 =