git.delta.rocks / jrsonnet / refs/commits / 5a5b360a3403

difftreelog

feat unify shared and host secret handling

xwkwvyrvYaroslav Bolyukin2026-01-22parent: #20a41a3.patch.diff
in: trunk

8 files changed

modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -8,9 +8,7 @@
 use chrono::{DateTime, Utc};
 use clap::Parser;
 use fleet_base::{
-	fleetdata::{
-		FleetHostSecret, FleetSecretData, FleetSecretPart, FleetSharedSecret, encrypt_secret_data,
-	},
+	fleetdata::{FleetSecretData, FleetSecretDistribution, FleetSecretPart, encrypt_secret_data},
 	host::Config,
 	opts::FleetOpts,
 	secret::{Expectations, RegenerationReason, SharedSecretDefinition, secret_needs_regeneration},
@@ -28,101 +26,25 @@
 	AddManager,
 	/// Force load host keys for all defined hosts
 	ForceKeys,
-	/// Add secret, data should be provided in stdin
-	AddShared {
-		/// Secret name
+	/// Read secret from remote host, requires sudo on one of the owning hosts
+	Read {
+		/// Secret name to read
 		name: String,
-		/// Secret owners
-		#[clap(long, short)]
-		machines: Vec<String>,
-		/// Override secret if already present
-		#[clap(long)]
-		force: bool,
-		/// Secret public part
-		#[clap(long)]
-		public: Option<String>,
-		/// Load public part from specified file
-		#[clap(long)]
-		public_file: Option<PathBuf>,
-
-		/// Create a notification on secret expiration
-		#[clap(long)]
-		expires_at: Option<DateTime<Utc>>,
 
-		/// Secret with this name already exists, override its value while keeping the same owners.
-		#[clap(long)]
-		re_add: bool,
-
-		/// How to name public secret part
-		#[clap(long, short = 'p', default_value = "public")]
-		public_part: String,
-		/// How to name private secret part
-		#[clap(short = 's', long, default_value = "secret")]
-		part: String,
-	},
-	/// Add secret, data should be provided in stdin
-	Add {
-		/// Secret name
-		name: String,
-		/// Secret owner
+		/// Distribution with what machine to read
+		/// If not shared between multiple - defaults to single owner
 		#[clap(short = 'm', long)]
-		machine: String,
-		/// Replace secret if already present
-		#[clap(long)]
-		replace: bool,
-		/// Add new parts to existing secret
-		#[clap(long)]
-		merge: bool,
-		/// Secret public part
-		#[clap(long)]
-		public: Option<String>,
-		/// Load public part from specified file
-		#[clap(long)]
-		public_file: Option<PathBuf>,
+		machine: Option<String>,
 
-		/// How to name public secret part
-		#[clap(short = 'p', long, default_value = "public")]
-		public_part: String,
-		/// How to name private secret part
-		#[clap(short = 's', long, default_value = "secret")]
-		part: String,
-	},
-	/// Read secret from remote host, requires sudo on said host
-	Read {
-		name: String,
-		#[clap(short = 'm', long)]
-		machine: String,
-
 		/// Which private secret part to read
 		#[clap(short = 'p', long, default_value = "secret")]
 		part: String,
-	},
-	/// Read secret from remote host, requires sudo on said host
-	ReadShared {
-		name: String,
-		/// Which private secret part to read
-		#[clap(short = 'p', long, default_value = "secret")]
-		part: String,
+
 		/// Which host should we use to decrypt, in case if reencryption is required, without
 		/// regeneration
 		#[clap(long)]
 		prefer_identities: Vec<String>,
 	},
-	UpdateShared {
-		name: String,
-
-		#[clap(short = 'm', long)]
-		machine: Option<Vec<String>>,
-
-		#[clap(long)]
-		add_machine: Vec<String>,
-		#[clap(long)]
-		remove_machine: Vec<String>,
-
-		/// Which host should we use to decrypt
-		#[clap(long)]
-		prefer_identities: Vec<String>,
-	},
 	Regenerate {
 		/// Which host should we use to decrypt, in case if reencryption is required, without
 		/// regeneration
@@ -152,11 +74,11 @@
 async fn maybe_regenerate_shared_secret(
 	secret_name: &str,
 	config: &Config,
-	mut secret: FleetSharedSecret,
+	mut secret: FleetSecretDistribution,
 	definition: SharedSecretDefinition,
 	prefer_identities: &[String],
 	expectations: &Expectations,
-) -> Result<FleetSharedSecret> {
+) -> Result<FleetSecretDistribution> {
 	let reason = secret_needs_regeneration(&secret.secret, &secret.owners, expectations);
 	let value = definition.definition_value();
 
@@ -397,9 +319,9 @@
 	display_name: &str,
 	secret: SharedSecretDefinition,
 	expectations: &Expectations,
-) -> Result<FleetSharedSecret> {
+) -> Result<FleetSecretDistribution> {
 	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);
-	Ok(FleetSharedSecret {
+	Ok(FleetSecretDistribution {
 		managed: Some(true),
 		secret: generate(
 			config,
@@ -504,177 +426,41 @@
 					config.key(&host.name).await?;
 				}
 			}
-			Secret::AddShared {
-				machines,
-				name,
-				force,
-				public,
-				public_part: public_name,
-				public_file,
-				expires_at,
-				re_add,
-				part: part_name,
-			} => {
-				let mut machines: BTreeSet<String> = machines.into_iter().collect();
-				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
-
-				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.iter().cloned().collect())
-					.await?;
-
-				let mut parts = BTreeMap::new();
-
-				let mut input = vec![];
-				io::stdin().read_to_end(&mut input)?;
-
-				if !input.is_empty() {
-					let encrypted = encrypt_secret_data(recipients.iter(), input)
-						.ok_or_else(|| anyhow!("no recipients provided"))?;
-					parts.insert(part_name, FleetSecretPart { raw: encrypted });
-				}
-
-				if let Some(public) = parse_public(public, public_file).await? {
-					parts.insert(public_name, FleetSecretPart { raw: public });
-				}
-
-				config.replace_shared(
-					name,
-					FleetSharedSecret {
-						managed: Some(false),
-						owners: machines,
-						secret: FleetSecretData {
-							created_at: Utc::now(),
-							expires_at,
-							parts,
-							generation_data: serde_json::Value::Null,
-						},
-					},
-				);
-			}
-			Secret::Add {
-				machine,
-				name,
-				replace,
-				merge,
-				public,
-				public_part: public_name,
-				public_file,
-				part: part_name,
-			} => {
-				if config.has_secret(&machine, &name) && !replace && !merge {
-					bail!(
-						"secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret"
-					);
-				}
-
-				let mut out = if merge && !replace {
-					config
-						.host_secret(&machine, &name)
-						.context("failed to read existing secret for --merge")?
-				} else {
-					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], 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, 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, use --replace if you wish to replace it"
-						);
-					}
-				};
-
-				config.insert_secret(&machine, name, out);
-			}
-			#[allow(clippy::await_holding_refcell_ref)]
 			Secret::Read {
 				name,
 				machine,
 				part: part_name,
+				mut prefer_identities,
 			} => {
-				let secret = config.host_secret(&machine, &name)?;
-				let Some(secret) = secret.secret.parts.get(&part_name) else {
-					bail!("no part {part_name} in secret {name}");
+				let Some(secret) = config.shared_secret(&name) else {
+					bail!("secret doesn't exists");
 				};
-				let data = if secret.raw.encrypted {
-					let host = config.host(&machine).await?;
-					host.decrypt(secret.raw.clone()).await?
+
+				let dist = if secret.len() == 1 {
+					&secret[0]
+				} else if let Some(machine) = machine {
+					let dist = secret.get(&machine);
+					let Some(dist) = dist else {
+						bail!("machine {machine} has no distribution of secret {name}");
+					};
+					prefer_identities.push(machine);
+					dist
 				} else {
-					secret.raw.data.clone()
+					bail!(
+						"secret {name} has shares, but no --machine specified for specifing which do you need"
+					)
 				};
 
-				stdout().write_all(&data)?;
-			}
-			Secret::ReadShared {
-				name,
-				part: part_name,
-				prefer_identities,
-			} => {
-				let Some(secret) = config.shared_secret(&name)? else {
-					bail!("secret doesn't exists");
-				};
-				let Some(part) = secret.secret.parts.get(&part_name) else {
+				let Some(part) = dist.secret.parts.get(&part_name) else {
 					bail!("no part {part_name} in secret {name}");
 				};
 				let data = if part.raw.encrypted {
 					let identity_holder = if !prefer_identities.is_empty() {
 						prefer_identities
 							.iter()
-							.find(|i| secret.owners.iter().any(|s| s == *i))
+							.find(|i| dist.owners.iter().any(|s| s == *i))
 					} else {
-						secret.owners.first()
+						dist.owners.first()
 					};
 					let Some(identity_holder) = identity_holder else {
 						bail!("no available holder found");
@@ -686,198 +472,148 @@
 				};
 				stdout().write_all(&data)?;
 			}
-			Secret::UpdateShared {
-				name,
-				machine,
-				add_machine,
-				remove_machine,
-				prefer_identities,
-			} => {
-				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).
-
-				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");
-				}
-
-				let initial_machines = secret.owners.clone();
-				let target_machines = parse_machines(
-					initial_machines.clone(),
-					machine,
-					add_machine,
-					remove_machine,
-				)?;
-
-				if target_machines.is_empty() {
-					info!("no machines left for secret, removing it");
-					config.remove_shared(&name);
-					return Ok(());
-				}
-
-				let definition = config.shared_secret_definition(&name)?;
-				let expectations = definition
-					.expectations()
-					.with_context(|| format!("expectations for shared {name:?}"))?;
-
-				let updated = maybe_regenerate_shared_secret(
-					&name,
-					config,
-					secret,
-					definition,
-					&prefer_identities,
-					&expectations,
-				)
-				.await?;
-				config.replace_shared(name, updated);
-			}
 			Secret::Regenerate {
 				prefer_identities,
 				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();
-					for missing in expected_shared_set.difference(&stored_shared_set) {
-						let definition = config.shared_secret_definition(missing)?;
-						if !definition.is_managed()? {
-							info!("skipping unmanaged secret: {missing}");
-							continue;
-						}
-						let expectations = definition
-							.expectations()
-							.with_context(|| format!("expectations for shared {missing:?}"))?;
-						info!("generating secret: {missing}");
-						let shared = generate_shared(config, missing, definition, &expectations)
-							.in_current_span()
-							.await?;
-						config.replace_shared(missing.to_string(), shared)
-					}
-				}
-				if !skip_hosts {
-					for host in config.list_hosts().await? {
-						if opts.should_skip(&host).await? {
-							continue;
-						}
+				/*
+								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_secrets().into_iter().collect::<HashSet<_>>();
+								{
+									// Generate missing shared
+									let _span = info_span!("shared").entered();
+									for missing in expected_shared_set.difference(&stored_shared_set) {
+										let definition = config.shared_secret_definition(missing)?;
+										if !definition.is_managed()? {
+											info!("skipping unmanaged secret: {missing}");
+											continue;
+										}
+										let expectations = definition
+											.expectations()
+											.with_context(|| format!("expectations for shared {missing:?}"))?;
+										info!("generating secret: {missing}");
+										let shared = generate_shared(config, missing, definition, &expectations)
+											.in_current_span()
+											.await?;
+										config.replace_shared(missing.to_string(), shared)
+									}
+								}
+								if !skip_hosts {
+									for host in config.list_hosts().await? {
+										if opts.should_skip(&host).await? {
+											continue;
+										}
 
-						let _span = info_span!("host", host = host.name).entered();
-						let expected_set = host
-							.list_defined_secrets()?
-							.into_iter()
-							.collect::<HashSet<_>>();
-						let stored_set = config
-							.list_secrets(&host.name)
-							.into_iter()
-							.collect::<HashSet<_>>();
-						for missing_secret in expected_set.difference(&stored_set) {
-							let secret = host.secret_definition(missing_secret)?;
-							if secret.is_shared()? {
-								continue;
-							}
-							info!("generating missing secret: {missing_secret}");
-							let expectations = secret.expectations().with_context(|| {
-								format!("expectations for {missing_secret:?} of {:?}", host.name)
-							})?;
-							let generated = match generate(
-								config,
-								missing_secret,
-								secret.definition_value()?,
-								&expectations,
-							)
-							.in_current_span()
-							.await
-							{
-								Ok(v) => v,
-								Err(e) => {
-									error!("{e:?}");
-									continue;
-								}
-							};
-							config.insert_secret(
-								&host.name,
-								missing_secret.to_string(),
-								FleetHostSecret {
-									managed: Some(true),
-									secret: generated,
-								},
-							)
-						}
-						for known_secret in stored_set.intersection(&expected_set) {
-							let secret = host.secret_definition(known_secret)?;
-							if secret.is_shared()? {
-								continue;
-							}
-							info!("updating secret: {known_secret}");
-							let data = config.host_secret(&host.name, known_secret)?;
-							let expectations = secret.expectations()?;
-							if let Some(regen_reason) = data.needs_regeneration(&expectations) {
-								info!("needs regeneration: {regen_reason}");
-								let generated = match generate(
-									config,
-									known_secret,
-									secret.definition_value()?,
-									&expectations,
-								)
-								.in_current_span()
-								.await
-								{
-									Ok(v) => v,
-									Err(e) => {
-										error!("{e:?}");
-										continue;
+										let _span = info_span!("host", host = host.name).entered();
+										let expected_set = host
+											.list_defined_secrets()?
+											.into_iter()
+											.collect::<HashSet<_>>();
+										let stored_set = config
+											.list_secrets_for_owner(&host.name)
+											.into_iter()
+											.collect::<HashSet<_>>();
+										for missing_secret in expected_set.difference(&stored_set) {
+											let secret = host.secret_definition(missing_secret)?;
+											if secret.is_shared()? {
+												continue;
+											}
+											info!("generating missing secret: {missing_secret}");
+											let expectations = secret.expectations().with_context(|| {
+												format!("expectations for {missing_secret:?} of {:?}", host.name)
+											})?;
+											let generated = match generate(
+												config,
+												missing_secret,
+												secret.definition_value()?,
+												&expectations,
+											)
+											.in_current_span()
+											.await
+											{
+												Ok(v) => v,
+												Err(e) => {
+													error!("{e:?}");
+													continue;
+												}
+											};
+											config.insert_secret(host.name, missing_secret.to_string(), generated)
+										}
+										for known_secret in stored_set.intersection(&expected_set) {
+											let secret = host.secret_definition(known_secret)?;
+											if secret.is_shared()? {
+												continue;
+											}
+											info!("updating secret: {known_secret}");
+											let data = config.host_secret(&host.name, known_secret)?;
+											let expectations = secret.expectations()?;
+											if let Some(regen_reason) = data.needs_regeneration(&expectations) {
+												info!("needs regeneration: {regen_reason}");
+												let generated = match generate(
+													config,
+													known_secret,
+													secret.definition_value()?,
+													&expectations,
+												)
+												.in_current_span()
+												.await
+												{
+													Ok(v) => v,
+													Err(e) => {
+														error!("{e:?}");
+														continue;
+													}
+												};
+												config.insert_secret(
+													&host.name,
+													known_secret.to_string(),
+													FleetLegacyHostSecret {
+														managed: Some(true),
+														secret: generated,
+													},
+												)
+											}
+										}
+										for removed_secret in stored_set.difference(&expected_set) {
+											let definition = host.secret_definition(removed_secret)?;
+											if definition.is_shared()? {
+												continue;
+											}
+											info!("removing secret: {removed_secret}");
+											config.remove_secret(&host.name, removed_secret);
+										}
 									}
-								};
-								config.insert_secret(
-									&host.name,
-									known_secret.to_string(),
-									FleetHostSecret {
-										managed: Some(true),
-										secret: generated,
-									},
-								)
-							}
-						}
-						for removed_secret in stored_set.difference(&expected_set) {
-							let definition = host.secret_definition(removed_secret)?;
-							if definition.is_shared()? {
-								continue;
-							}
-							info!("removing secret: {removed_secret}");
-							config.remove_secret(&host.name, removed_secret);
-						}
-					}
-				}
-				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");
+								}
+								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 definition = config.shared_secret_definition(known_secret)?;
-					let expectations = definition.expectations()?;
-					config.replace_shared(
-						known_secret.to_owned(),
-						maybe_regenerate_shared_secret(
-							known_secret,
-							config,
-							data,
-							definition,
-							&prefer_identities,
-							&expectations,
-						)
-						.await?,
-					);
-				}
-				for removed_secret in stored_shared_set.difference(&expected_shared_set) {
-					info!("removing shared secret: {removed_secret}");
-					config.remove_shared(removed_secret);
-				}
+									let definition = config.shared_secret_definition(known_secret)?;
+									let expectations = definition.expectations()?;
+									config.replace_shared(
+										known_secret.to_owned(),
+										maybe_regenerate_shared_secret(
+											known_secret,
+											config,
+											data,
+											definition,
+											&prefer_identities,
+											&expectations,
+										)
+										.await?,
+									);
+								}
+								for removed_secret in stored_shared_set.difference(&expected_shared_set) {
+									info!("removing shared secret: {removed_secret}");
+									config.remove_shared(removed_secret);
+								}
+				*/
+				todo!()
 			}
 			Secret::List {} => {
 				let _span = info_span!("loading secrets").entered();
@@ -892,12 +628,11 @@
 				let mut table = vec![];
 				for name in configured.iter().cloned() {
 					let config = config.clone();
-					let data = config.shared_secret(&name)?.expect("exists");
+					let data = config.shared_secret(&name).expect("exists");
 					let definition = config.shared_secret_definition(&name)?;
 					let expectations = definition.expectations()?;
 					let owners = data
-						.owners
-						.iter()
+						.owners()
 						.map(|o| {
 							if expectations.owners.contains(o) {
 								o.green().to_string()
@@ -919,7 +654,9 @@
 				part,
 				add,
 			} => {
-				let secret = config.host_secret(&machine, &name)?;
+				let secret = config
+					.host_secret(&machine, &name)
+					.context("secret not found")?;
 				if let Some(data) = secret.secret.parts.get(&part) {
 					let host = config.host(&machine).await?;
 					let secret = host.decrypt(data.raw.clone()).await?;
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
1use std::{1use std::{
2 collections::{BTreeMap, BTreeSet},2 collections::{
3 BTreeMap, BTreeSet,
4 btree_map::{self, Entry},
5 },
3 io::{self, Cursor},6 io::{self, Cursor},
7 ops::Deref,
4};8};
59
6use age::Recipient;10use age::Recipient;
12};16};
13use serde::{Deserialize, Serialize, de::Error};17use serde::{
18 Deserialize, Serialize,
19 de::{self, Error},
20};
14use serde_json::Value;21use serde_json::Value;
15
16use crate::secret::{Expectations, RegenerationReason, secret_needs_regeneration};22use tracing::info;
1723
18#[derive(Serialize, Deserialize, Default)]24#[derive(Serialize, Deserialize, Default)]
19#[serde(rename_all = "camelCase")]25#[serde(rename_all = "camelCase")]
73 #[serde(default)]79 #[serde(default)]
74 pub hosts: BTreeMap<String, HostData>,80 pub hosts: BTreeMap<String, HostData>,
81
75 #[serde(default)]82 #[serde(default, alias = "shared_secrets")]
76 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
77 pub shared_secrets: BTreeMap<String, FleetSharedSecret>,83 pub secrets: FleetSecrets,
84
85 // extra_name => anything
78 #[serde(default)]86 #[serde(default)]
79 #[serde(skip_serializing_if = "BTreeMap::is_empty")]87 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
80 pub host_secrets: BTreeMap<String, BTreeMap<String, FleetHostSecret>>,88 pub extra: BTreeMap<String, Value>,
8189
82 // extra_name => anything
83 #[serde(default)]90 #[serde(default)]
84 #[serde(skip_serializing_if = "BTreeMap::is_empty")]91 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
85 pub extra: BTreeMap<String, Value>,92 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
86}93}
94impl FleetData {
95 pub fn from_str(s: &str) -> anyhow::Result<Self> {
96 let mut data: Self = nixlike::parse_str(s)?;
97 if !data.host_secrets.is_empty() {
98 info!("migrating host secrets into shared secrets structure");
99 data.secrets
100 .merge_from_hosts(std::mem::take(&mut data.host_secrets));
101 }
102 Ok(data)
103 }
104}
87105
88/// Returns None if recipients.is_empty()106/// Returns None if recipients.is_empty()
89pub fn encrypt_secret_data<'r>(107pub fn encrypt_secret_data<'r>(
129#[derive(Serialize, Deserialize, Clone)]147#[derive(Serialize, Deserialize, Clone)]
130#[serde(rename_all = "camelCase")]148#[serde(rename_all = "camelCase")]
131#[must_use]149#[must_use]
132pub struct FleetHostSecret {150pub struct FleetSecretDistribution {
133 #[serde(default)]151 #[serde(default)]
134 #[serde(skip_serializing_if = "Option::is_none")]152 #[serde(skip_serializing_if = "Option::is_none")]
135 pub managed: Option<bool>,153 pub managed: Option<bool>,
154 #[serde(default)]
155 pub owners: BTreeSet<String>,
136 #[serde(flatten)]156 #[serde(flatten)]
137 pub secret: FleetSecretData,157 pub secret: FleetSecretData,
138}158}
159
160#[derive(Clone)]
161#[must_use]
162pub struct FleetSecretDistributions(Vec<FleetSecretDistribution>);
163
164impl Deref for FleetSecretDistributions {
165 type Target = [FleetSecretDistribution];
166
167 fn deref(&self) -> &Self::Target {
168 self.0.as_slice()
169 }
170}
171
139impl FleetHostSecret {172impl FleetSecretDistributions {
173 pub fn owners(&self) -> impl Iterator<Item = &String> {
174 self.0.iter().flat_map(|v| v.owners.iter())
175 }
176 #[allow(
177 clippy::len_without_is_empty,
178 reason = "should not be empty for a long time"
179 )]
180 pub fn len(&self) -> usize {
181 self.0.len()
182 }
183
140 pub fn needs_regeneration(&self, expectations: &Expectations) -> Option<RegenerationReason> {184 pub fn get(&self, owner: &str) -> Option<&FleetSecretDistribution> {
141 secret_needs_regeneration(&self.secret, &expectations.owners, expectations)185 self.0.iter().find(|d| d.owners.contains(owner))
142 }186 }
187 fn entry(&mut self, owner: String) -> DistEntry<'_> {
188 let Some(idx) = self.0.iter().position(|d| d.owners.contains(&owner)) else {
189 return DistEntry::Vacant(VacantDistEntry {
190 distributions: self,
191 owner,
192 });
193 };
194 DistEntry::Occupied(OccupiedDistEntry {
195 distributions: self,
196 idx,
197 owner,
198 })
199 }
200 fn extend(&mut self, dist: FleetSecretDistribution) {
201 for owner in &dist.owners {
202 self.entry(owner.to_owned()).remove();
203 }
204 self.0.push(dist);
205 }
206 pub fn contains(&self, owner: &str) -> bool {
207 self.0.iter().any(|d| d.owners.contains(owner))
208 }
143}209}
144210
211struct OccupiedDistEntry<'d> {
212 distributions: &'d mut FleetSecretDistributions,
213 idx: usize,
214 owner: String,
215}
216impl<'d> OccupiedDistEntry<'d> {
217 fn remove(self) -> VacantDistEntry<'d> {
218 let dist = &mut self.distributions.0[self.idx];
219 assert!(
220 dist.owners.remove(&self.owner),
221 "entry exists, as we have its reference"
222 );
223 if dist.owners.is_empty() {
224 self.distributions.0.remove(self.idx);
225 }
226 VacantDistEntry {
227 distributions: self.distributions,
228 owner: self.owner,
229 }
230 }
231 fn set(self, secret: FleetSecretData) -> Self {
232 self.remove().set(secret)
233 }
234}
235struct VacantDistEntry<'d> {
236 distributions: &'d mut FleetSecretDistributions,
237 owner: String,
238}
239impl<'d> VacantDistEntry<'d> {
240 fn set(self, secret: FleetSecretData) -> OccupiedDistEntry<'d> {
241 let Self {
242 distributions,
243 owner,
244 } = self;
245 let idx = distributions.0.len();
246 distributions.0.push(FleetSecretDistribution {
247 managed: None,
248 owners: BTreeSet::from_iter([owner.clone()]),
249 secret,
250 });
251 OccupiedDistEntry {
252 distributions,
253 owner,
254 idx,
255 }
256 }
257}
258
259enum DistEntry<'d> {
260 Vacant(VacantDistEntry<'d>),
261 Occupied(OccupiedDistEntry<'d>),
262}
263impl DistEntry<'_> {
264 fn remove(self) -> Self {
265 match self {
266 DistEntry::Vacant(_) => self,
267 DistEntry::Occupied(o) => Self::Vacant(o.remove()),
268 }
269 }
270 fn set(self, secret: FleetSecretData) -> Self {
271 Self::Occupied(match self {
272 DistEntry::Vacant(e) => e.set(secret),
273 DistEntry::Occupied(e) => e.set(secret),
274 })
275 }
276}
277
278impl Serialize for FleetSecretDistributions {
279 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
280 where
281 S: serde::Serializer,
282 {
283 let mut found_hosts = BTreeSet::new();
284 for ele in self.0.iter() {
285 if ele.owners.is_empty() {
286 panic!("consistency: secret distribution has no defined owners");
287 }
288 for ele in ele.owners.iter() {
289 if !found_hosts.insert(ele) {
290 panic!(
291 "consistency: secret distribution contains duplicate entry for the same host",
292 );
293 }
294 }
295 }
296 match self.0.len() {
297 0 => panic!("consistency: empty distributions"),
298 1 => self.0[0].serialize(serializer),
299 _ => self.0.serialize(serializer),
300 }
301 }
302}
303impl<'de> Deserialize<'de> for FleetSecretDistributions {
304 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
305 where
306 D: serde::Deserializer<'de>,
307 {
308 #[derive(Deserialize)]
309 #[serde(untagged)]
310 enum Distributions {
311 One(FleetSecretDistribution),
312 Many(Vec<FleetSecretDistribution>),
313 }
314 let d = Distributions::deserialize(deserializer)?;
315 let ds = match d {
316 Distributions::One(d) => vec![d],
317 Distributions::Many(ds) => ds,
318 };
319 if ds.is_empty() {
320 return Err(de::Error::custom("consistency: empty distributions"));
321 }
322 let mut found_hosts = BTreeSet::new();
323 for ele in ds.iter() {
324 if ele.owners.is_empty() {
325 return Err(de::Error::custom(
326 "consistency: secret distribution has no defined owners",
327 ));
328 }
329 for ele in ele.owners.iter() {
330 if !found_hosts.insert(ele) {
331 return Err(de::Error::custom(
332 "consistency: secret distribution contains duplicate entry for the same host",
333 ));
334 }
335 }
336 }
337 Ok(Self(ds))
338 }
339}
340
341#[derive(Serialize, Deserialize, Default)]
342pub struct FleetSecrets(BTreeMap<String, FleetSecretDistributions>);
343
344impl FleetSecrets {
345 pub fn keys(&self) -> btree_map::Keys<String, FleetSecretDistributions> {
346 self.0.keys()
347 }
348
349 pub fn keys_for_owner(&self, owner: &str) -> impl Iterator<Item = &String> {
350 self.0
351 .iter()
352 .filter(|(_, d)| d.contains(owner))
353 .map(|(n, _)| n)
354 }
355
356 pub fn drop_owner_no_reencrypt(&mut self, secret: &str, owner: &str) -> bool {
145#[derive(Serialize, Deserialize, Clone)]357 let Entry::Occupied(mut dists) = self.0.entry(secret.to_owned()) else {
358 return false;
359 };
146#[serde(rename_all = "camelCase")]360 let DistEntry::Occupied(dist) = dists.get_mut().entry(owner.to_owned()) else {
147#[must_use]361 return false;
362 };
363
364 dist.remove();
365
366 if dists.get().0.is_empty() {
367 dists.remove();
368 };
369
370 true
371 }
148pub struct FleetSharedSecret {372 pub fn set_single_data(&mut self, secret: String, owner: String, data: FleetSecretData) {
149 #[serde(default)]373 let e = self
374 .0
375 .entry(secret.to_owned())
376 .or_insert_with(|| FleetSecretDistributions(Default::default()));
377 e.entry(owner.to_owned()).set(data);
378 }
379 pub fn set_data(&mut self, secret: String, data: FleetSecretDistribution) {
380 match self.0.entry(secret) {
381 Entry::Vacant(e) => {
382 e.insert(FleetSecretDistributions(vec![data]));
383 }
384 Entry::Occupied(mut e) => {
385 let dists = e.get_mut();
386 dists.extend(data)
387 }
388 }
389 }
390 pub fn get_single(&self, secret: &str, owner: &str) -> Option<&FleetSecretDistribution> {
150 #[serde(skip_serializing_if = "Option::is_none")]391 let secret = self.0.get(secret)?;
392 secret.get(owner)
393 }
151 pub managed: Option<bool>,394 pub fn get(&self, secret: &str) -> Option<&FleetSecretDistributions> {
395 self.0.get(secret)
396 }
397
152 pub owners: BTreeSet<String>,398 pub fn contains_for_owner(&self, secret: &str, owner: &str) -> bool {
399 let Some(secret) = self.0.get(secret) else {
400 return false;
401 };
402 secret.contains(owner)
403 }
404 pub fn contains(&self, secret: &str) -> bool {
153 #[serde(flatten)]405 self.0.contains_key(secret)
406 }
154 pub secret: FleetSecretData,407 pub fn remove(&mut self, secret: &str) {
155}408 self.0.remove(secret);
409 }
410
411 fn merge_from_hosts(
412 &mut self,
413 host_secrets: BTreeMap<String, BTreeMap<String, FleetSecretDistribution>>,
414 ) {
415 for (host, host_secrets) in host_secrets {
416 for (secret_name, mut secret_data) in host_secrets {
417 secret_data.owners.insert(host.clone());
418 self.set_data(secret_name, secret_data);
419 }
420 }
421 }
422}
156423
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/host.rs
+++ b/crates/fleet-base/src/host.rs
@@ -22,7 +22,7 @@
 
 use crate::{
 	command::MyCommand,
-	fleetdata::{FleetData, FleetHostSecret, FleetSharedSecret},
+	fleetdata::{FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretDistributions},
 	secret::{HostSecretDefinition, SharedSecretDefinition},
 };
 
@@ -623,80 +623,48 @@
 		let config_field = &self.config_field;
 		nix_go!(config_field.sharedSecrets).list_fields()
 	}
-	/// Shared secrets configured in fleet.nix
-	pub fn list_shared(&self) -> Vec<String> {
-		let data = self.data();
-		data.shared_secrets.keys().cloned().collect()
-	}
 	pub fn has_shared(&self, name: &str) -> bool {
 		let data = self.data();
-		data.shared_secrets.contains_key(name)
+		data.secrets.contains(name)
 	}
-	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {
+	pub fn replace_shared(&self, name: String, shared: FleetSecretDistribution) {
 		let mut data = self.data_mut();
-		data.shared_secrets.insert(name.to_owned(), shared);
+		data.secrets.set_data(name, shared);
 	}
 	pub fn remove_shared(&self, secret: &str) {
 		let mut data = self.data_mut();
-		data.shared_secrets.remove(secret);
+		data.secrets.remove(secret);
 	}
 
-	pub fn list_secrets(&self, host: &str) -> Vec<String> {
-		let data = self.data();
-		let mut out = data
-			.host_secrets
-			.get(host)
-			.map(|s| s.keys().cloned().collect::<Vec<String>>())
-			.unwrap_or_default();
-
-		for (name, shared) in data.shared_secrets.iter() {
-			if shared.owners.contains(host) {
-				out.push(name.clone());
-			}
-		}
-
-		out
+	pub fn list_secrets_for_owner(&self, host: &str) -> Vec<String> {
+		let data = self.data_mut();
+		data.secrets.keys_for_owner(host).cloned().collect()
+	}
+	pub fn list_secrets(&self) -> Vec<String> {
+		let data = self.data_mut();
+		data.secrets.keys().cloned().collect()
 	}
 
 	pub fn has_secret(&self, host: &str, secret: &str) -> bool {
 		let data = self.data();
-		let Some(host_secrets) = data.host_secrets.get(host) else {
-			return false;
-		};
-		host_secrets.contains_key(secret)
+		data.secrets.contains_for_owner(secret, host)
 	}
-	pub fn insert_secret(&self, host: &str, secret: String, value: FleetHostSecret) {
+	pub fn insert_secret(&self, host: String, secret: String, value: FleetSecretData) {
 		let mut data = self.data_mut();
-		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
-		host_secrets.insert(secret, value);
+		data.secrets.set_single_data(secret, host, value);
 	}
 	pub fn remove_secret(&self, host: &str, secret: &str) {
 		let mut data = self.data_mut();
-		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();
-		host_secrets.remove(secret);
+		data.secrets.drop_owner_no_reencrypt(secret, host);
 	}
 
-	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetHostSecret> {
+	pub fn host_secret(&self, host: &str, secret: &str) -> Option<FleetSecretDistribution> {
 		let data = self.data();
-		if let Some(host_secrets) = data.host_secrets.get(host) {
-			if let Some(secret) = host_secrets.get(secret) {
-				return Ok(secret.clone());
-			}
-		};
-		let Some(shared) = data.shared_secrets.get(secret) else {
-			bail!("machine {host} has no secret {secret}");
-		};
-		if !shared.owners.contains(host) {
-			bail!("shared secret {secret} is not owned by {host}");
-		};
-		Ok(FleetHostSecret {
-			managed: shared.managed,
-			secret: shared.secret.clone(),
-		})
+		data.secrets.get_single(secret, host).cloned()
 	}
-	pub fn shared_secret(&self, secret: &str) -> Result<Option<FleetSharedSecret>> {
+	pub fn shared_secret(&self, secret: &str) -> Option<FleetSecretDistributions> {
 		let data = self.data();
-		Ok(data.shared_secrets.get(secret).cloned())
+		data.secrets.get(secret).cloned()
 	}
 	pub fn shared_secret_definition(&self, secret: &str) -> Result<SharedSecretDefinition> {
 		let config_field = &self.config_field;
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -211,7 +211,7 @@
 		}
 		let bytes =
 			std::fs::read_to_string(&fleet_data_path).context("reading fleet state (fleet.nix)")?;
-		let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;
+		let data = Mutex::new(FleetData::from_str(&bytes)?);
 
 		let mut fetch_settings = FetchSettings::new();
 		fetch_settings.set(c"warn-dirty", c"false");
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -2,10 +2,10 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1766181779,
+        "lastModified": 1767461147,
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "0263f510ba38bee5b7f817498066adaad694e50b",
+        "rev": "7d59256814085fd9666a2ae3e774dc5ee216b630",
         "type": "github"
       },
       "original": {
@@ -37,10 +37,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1765835352,
+        "lastModified": 1767609335,
         "owner": "hercules-ci",
         "repo": "flake-parts",
-        "rev": "a34fae9c08a15ad73f295041fec82323541400a9",
+        "rev": "250481aafeb741edfe23d29195671c19b36b6dca",
         "type": "github"
       },
       "original": {
@@ -126,10 +126,10 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1766181714,
+        "lastModified": 1767657734,
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "ff2da5fee8b3248cac330f14eac98228620beab0",
+        "rev": "d4ccebf51ee4dbeb9df364dce1fe9848635c1258",
         "type": "github"
       },
       "original": {
@@ -190,10 +190,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1766112155,
+        "lastModified": 1767667566,
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "2a6db3fc1c27ae77f9caa553d7609b223cb770b5",
+        "rev": "056ce5b125ab32ffe78c7d3e394d9da44733c95e",
         "type": "github"
       },
       "original": {
@@ -223,10 +223,10 @@
         ]
       },
       "locked": {
-        "lastModified": 1766000401,
+        "lastModified": 1767468822,
         "owner": "numtide",
         "repo": "treefmt-nix",
-        "rev": "42d96e75aa56a3f70cab7e7dc4a32868db28e8fd",
+        "rev": "d56486eb9493ad9c4777c65932618e9c2d0468fc",
         "type": "github"
       },
       "original": {
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -128,11 +128,6 @@
               overlays = [
                 (inputs.rust-overlay.overlays.default)
                 (final: prev: {
-                  boehmgc = prev.boehmgc.overrideAttrs (prevAttrs: {
-                    configureFlags = prevAttrs.configureFlags ++ [
-                      "--enable-gc-assertions"
-                    ];
-                  });
                   # Libsecret is stupidly huge
                   # https://github.com/oxalica/rust-overlay/issues/211
                   libsecret = final.stdenv.mkDerivation {
modifiedmodules/secrets-data.nixdiffbeforeafterboth
--- a/modules/secrets-data.nix
+++ b/modules/secrets-data.nix
@@ -1,7 +1,6 @@
 {
   lib,
   fleetLib,
-  config,
   ...
 }:
 let
@@ -15,15 +14,7 @@
     submodule
     bool
     unspecified
-    ;
-  inherit (lib.attrsets)
-    mapAttrsToList
-    mapAttrs
-    filterAttrs
-    genAttrs
     ;
-  inherit (lib.lists) sort unique concatLists;
-  inherit (lib.strings) toJSON;
 
   secretDataValue = {
     options = {
@@ -71,35 +62,8 @@
         default = null;
       };
     };
-    config = { };
   };
 
-  hostSecretData = {
-    freeformType = attrsOf (submodule secretDataValue);
-    options = {
-      createdAt = mkOption {
-        type = str;
-        description = "Timestamp of secret generation/last rotation.";
-        default = null;
-      };
-      expiresAt = mkOption {
-        type = nullOr str;
-        description = "Expiration timestamp triggering mandatory secret rotation.";
-        default = null;
-      };
-      shared = mkOption {
-        type = bool;
-        description = "Indicates if secret is a shared secret, so other hosts might have the same piece of secret data.";
-        default = false;
-      };
-      generationData = mkOption {
-        type = unspecified;
-        description = "Contextual metadata associated with secret part.";
-        default = null;
-      };
-    };
-    config = { };
-  };
   managerKey = {
     options = {
       name = mkOption {
@@ -121,49 +85,11 @@
         managerKeys = mkOption {
           type = listOf (submodule managerKey);
         };
-        sharedSecrets = mkOption {
-          type = attrsOf (submodule sharedSecretData);
+        secrets = mkOption {
+          type = attrsOf (listOf submodule sharedSecretData);
           default = { };
           description = "Shared secret data.";
-        };
-        hostSecrets = mkOption {
-          type = attrsOf (attrsOf (submodule hostSecretData));
-          default = { };
-          description = "Host-specific secrets.";
-          internal = true;
         };
       };
-      config.hostSecrets =
-        let
-          hostsWithSharedSecrets = unique (
-            concatLists (mapAttrsToList (_: s: s.owners) config.sharedSecrets)
-          );
-          secretsHavingHost = host: filterAttrs (_: secret: lib.elem host secret.owners) config.sharedSecrets;
-          toHostSecret = _: secret: (removeAttrs secret [ "owners" ]) // { shared = true; };
-        in
-        genAttrs hostsWithSharedSecrets (host: mapAttrs toHostSecret (secretsHavingHost host));
     });
-  config = {
-    assertions =
-      (mapAttrsToList (name: secret: {
-        assertion =
-          secret.expectedOwners == null
-          ||
-            sort (a: b: a < b) (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners
-            == sort (a: b: a < b) secret.expectedOwners;
-        message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${
-          toJSON (config.data.sharedSecrets.${name} or { owners = [ ]; }).owners
-        }. Run fleet secrets regenerate to fix";
-      }) config.sharedSecrets)
-
-      ++ (mapAttrsToList (name: secret: {
-        # TODO: Same assertion should be in host secrets
-        assertion =
-          (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
-          == secret.expectedGenerationData;
-        message = "Shared secret ${name} has unexpected generation data ${toJSON secret.expectedGenerationData} != ${
-          toJSON (config.data.sharedSecrets.${name} or { generationData = null; }).generationData
-        }. Run fleet secrets regenerate to fix";
-      }) config.sharedSecrets);
-  };
 }
modifiedmodules/secrets.nixdiffbeforeafterboth
--- a/modules/secrets.nix
+++ b/modules/secrets.nix
@@ -1,6 +1,5 @@
 {
   lib,
-  config,
   ...
 }:
 let
@@ -18,7 +17,6 @@
     uniq
     ;
   inherit (lib.strings) concatStringsSep;
-  inherit (lib.attrsets) mapAttrs;
 
   sharedSecret =
     { config, ... }:
@@ -54,6 +52,12 @@
             Set to false if host permissions are revoked through alternative mechanisms like firewall rules.
           '';
         };
+        allowDifferent = mkOption {
+          type = bool;
+          description = ''
+            When adding owner, do not update secret value for other owners, instead creating a new distribution
+          '';
+        };
         generator = mkOption {
           type = uniq (nullOr (functionTo package));
           description = ''
@@ -84,32 +88,13 @@
 in
 {
   options = {
-    sharedSecrets = mkOption {
+    secrets = mkOption {
       type = attrsOf (submodule sharedSecret);
       default = { };
       description = "Collection of secrets shared across multiple hosts with configurable ownership";
     };
   };
   config = {
-    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 =