git.delta.rocks / jrsonnet / refs/commits / 9fe8d9f6e585

difftreelog

feat fleet secret list subcommand

vypslxyrYaroslav Bolyukin2026-04-18parent: #7b7c4bb.patch.diff
in: trunk

2 files changed

modifiedcmds/fleet/src/cmds/secrets.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets.rs
1use std::collections::BTreeSet;2use std::io::{Write as _, stdout};34use anyhow::{Context as _, Result, anyhow, bail};5use clap::Parser;6use fleet_base::{fleetdata::SecretOwner, host::Config, opts::FleetOpts};7use itertools::Itertools as _;8use nix_eval::nix_go;9use tracing::{info, warn};1011#[derive(Parser)]12pub enum Secret {13	/// Force load host keys for all defined hosts14	ForceKeys,15	/// Read secret from remote host, requires sudo on one of the owning hosts16	Read {17		/// Secret name to read18		name: String,1920		/// Distribution with what machine to read21		/// If not shared between multiple - defaults to single owner22		#[clap(short = 'm', long)]23		machine: Option<String>,2425		/// Which private secret part to read26		/// If not specified - only one existing part is read27		#[clap(short = 'p', long)]28		part: Option<String>,29	},30	/// Prune (remove, mark for regeneration) secrets31	Prune {32		/// Secret to prune33		name: String,3435		/// Machines to prune - if specified, only the choosen machines will be pruned36		#[clap(short = 'm', long)]37		machine: Vec<String>,3839		/// If set - distributions containing the specified machines will be pruned fully40		#[clap(long)]41		whole_dist: bool,42	},43	/// Ensure secret is generated and not expired44	Ensure {45		/// Secret to ensure generated46		name: String,4748		/// Machines to force secret for49		#[clap(short = 'm', long)]50		machine: Vec<String>,51	},52	/// Regenerate secret (prune then ensure)53	Regenerate {54		/// Secret to regenerate55		name: String,5657		/// Machines to regenerate for - if specified, only those machines58		#[clap(short = 'm', long)]59		machine: Vec<String>,60	},61	List {},62}6364impl Secret {65	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {66		match self {67			Secret::ForceKeys => {68				for host in config.list_hosts()? {69					if opts.should_skip(&host)? {70						continue;71					}72					config.host_key(&host.name).await?;73				}74			}75			Secret::Read {76				name,77				machine,78				part: part_name,79			} => {80				let (owners, secret_data) = {81					let secret = config.data.secrets.read().expect("not poisoned");8283					let Some(dist) = secret.get(&name) else {84						bail!("secret doesn't exists");85					};8687					let dist = if let Some(machine) = &machine {88						dist.get(&SecretOwner::host(machine))89							.ok_or_else(|| anyhow!("machine {machine} has no secret generated"))?90					} else {91						dist.distributions()92							.exactly_one()93							.map_err(|e| anyhow!("{e}"))94							.context(95								"with no machine specified, there should be exactly one distribution",96							)?97					};9899					let part = if let Some(part_name) = &part_name {100						dist.secret.parts.get(part_name).ok_or_else(|| {101							anyhow!("secret {name} does not have part named {part_name}")102						})?103					} else {104						dist.secret105							.parts106							.iter()107							.exactly_one()108							.map_err(|e| anyhow!("{e}"))109							.context("with no part specified, there should be exactly one part")?110							.1111					};112					let owners = dist.owners().cloned().collect::<Vec<_>>();113					let secret_data = part.raw.clone();114					(owners, secret_data)115				};116117				for host in config118					.preferred_hosts(|h| owners.iter().any(|o| o.as_host() == Some(h)))119					.context("failed to list hosts")?120				{121					let host = match host {122						Ok(h) => h,123						Err(e) => {124							warn!("failed to use host: {e}");125							continue;126						}127					};128					match host.decrypt(secret_data.clone()).await {129						Ok(data) => {130							let mut w = stdout();131							w.write_all(&data)?;132							return Ok(());133						}134						Err(e) => warn!("failed to decrypt on {}: {e}", host.name),135					};136				}137				bail!("failed to find suitable decrypting host");138			}139			Secret::List {} => {140				/*141				let _span = info_span!("loading secrets").entered();142				let configured = config.list_configured_shared()?;143				#[derive(Tabled)]144				struct SecretDisplay {145					#[tabled(rename = "Name")]146					name: String,147					#[tabled(rename = "Owners")]148					owners: String,149				}150				// let mut table = vec![];151				for name in configured.iter().cloned() {152					let config = config.clone();153					let data = config.shared_secret(&name).expect("exists");154					/*155										let definition = config.shared_secret_definition(&name)?;156										let expectations = definition.expectations()?;157										let owners = data158											.owners()159											.map(|o| {160												if expectations.owners.contains(o) {161													o.green().to_string()162												} else {163													o.red().to_string()164												}165											})166											.collect::<Vec<_>>();167										table.push(SecretDisplay {168											owners: owners.join(", "),169											name,170										})171					*/172				}173				// info!("loaded\n{}", Table::new(table).to_string())174				*/175				todo!()176			}177			Secret::Prune {178				name,179				machine,180				whole_dist,181			} => {182				Self::prune(config, &name, &machine, whole_dist)?;183			}184			Secret::Ensure { name, machine } => {185				Self::ensure(config, opts, &name, &machine)?;186			}187			Secret::Regenerate { name, machine } => {188				let pruned = Self::prune(config, &name, &machine, true)?;189				// In general, this is not correct - already evaluated secret would still be cached after pruning190				// But as a dedicated CLI subcommand it is safe to assume it was not evaluated yet191				Self::ensure(config, opts, &name, &pruned)?;192			}193		}194		Ok(())195	}196197	fn prune(198		config: &Config,199		name: &str,200		machine: &[String],201		whole_dist: bool,202	) -> Result<Vec<String>> {203		let mut secrets = config.data.secrets.write().expect("not poisoned");204		let Some(dists) = secrets.get_mut(name) else {205			bail!("secret {name} not found");206		};207		let owners_before: BTreeSet<String> = dists208			.owners()209			.filter_map(|o| o.as_host().map(str::to_owned))210			.collect();211212		if machine.is_empty() && whole_dist {213			for dist in dists.distributions_mut() {214				dist.prune("manual prune".to_owned());215			}216		} else if machine.is_empty() {217			let dist = dists218				.distributions_mut()219				.exactly_one()220				.map_err(|e| anyhow!("{e}"))221				.context("with no machine specified, there should be exactly one distribution")?;222			dist.prune("manual prune".to_owned());223		} else if whole_dist {224			for dist in dists.distributions_mut() {225				if machine226					.iter()227					.any(|m| dist.owners().any(|o| o.as_host() == Some(m.as_str())))228				{229					dist.prune(format!(230						"manual prune of distribution containing {}",231						machine.join(", ")232					));233				}234			}235		} else {236			let owners: BTreeSet<SecretOwner> = machine.iter().map(SecretOwner::host).collect();237			for dist in dists.distributions_mut() {238				dist.prune_owners(&owners, "manual prune".to_owned());239			}240		}241242		let owners_after: BTreeSet<String> = dists243			.owners()244			.filter_map(|o| o.as_host().map(str::to_owned))245			.collect();246		Ok(owners_before.difference(&owners_after).cloned().collect())247	}248249	fn ensure(config: &Config, opts: &FleetOpts, name: &str, machine: &[String]) -> Result<()> {250		let hosts: Vec<String> = if machine.is_empty() {251			config252				.list_hosts()?253				.into_iter()254				.filter(|h| opts.should_skip(h).ok() != Some(true))255				.map(|h| h.name)256				.collect()257		} else {258			machine.to_vec()259		};260261		for hostname in &hosts {262			let nixos_cfg = config.system_config(hostname)?;263			let secrets = nix_go!(nixos_cfg.secrets);264			if secrets.has_field(name)? {265				info!("ensuring secret {name} for {hostname}");266				// Force evaluation of secret parts, triggering __fleetEnsureHostSecret267				nix_go!(secrets[{ name }].definition.parts);268			}269		}270		Ok(())271	}272}
after · cmds/fleet/src/cmds/secrets.rs
1use std::collections::BTreeSet;2use std::io::{Write as _, stdout};34use anyhow::{Context as _, Result, anyhow, bail};5use clap::Parser;6use fleet_base::{fleetdata::SecretOwner, host::Config, opts::FleetOpts};7use itertools::Itertools as _;8use nix_eval::nix_go;9use tracing::{info, warn};1011#[derive(Parser)]12pub enum Secret {13	/// Force load host keys for all defined hosts14	ForceKeys,15	/// Read secret from remote host, requires sudo on one of the owning hosts16	Read {17		/// Secret name to read18		name: String,1920		/// Distribution with what machine to read21		/// If not shared between multiple - defaults to single owner22		#[clap(short = 'm', long)]23		machine: Option<String>,2425		/// Which private secret part to read26		/// If not specified - only one existing part is read27		#[clap(short = 'p', long)]28		part: Option<String>,29	},30	/// Prune (remove, mark for regeneration) secrets31	Prune {32		/// Secret to prune33		name: String,3435		/// Machines to prune - if specified, only the choosen machines will be pruned36		#[clap(short = 'm', long)]37		machine: Vec<String>,3839		/// If set - distributions containing the specified machines will be pruned fully40		#[clap(long)]41		whole_dist: bool,42	},43	/// Ensure secret is generated and not expired44	Ensure {45		/// Secret to ensure generated46		name: String,4748		/// Machines to force secret for49		#[clap(short = 'm', long)]50		machine: Vec<String>,51	},52	/// Regenerate secret (prune then ensure)53	Regenerate {54		/// Secret to regenerate55		name: String,5657		/// Machines to regenerate for - if specified, only those machines58		#[clap(short = 'm', long)]59		machine: Vec<String>,60	},61	List {},62}6364impl Secret {65	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {66		match self {67			Secret::ForceKeys => {68				for host in config.list_hosts()? {69					if opts.should_skip(&host)? {70						continue;71					}72					config.host_key(&host.name).await?;73				}74			}75			Secret::Read {76				name,77				machine,78				part: part_name,79			} => {80				let (owners, secret_data) = {81					let secret = config.data.secrets.read().expect("not poisoned");8283					let Some(dist) = secret.get(&name) else {84						bail!("secret doesn't exists");85					};8687					let dist = if let Some(machine) = &machine {88						dist.get(&SecretOwner::host(machine))89							.ok_or_else(|| anyhow!("machine {machine} has no secret generated"))?90					} else {91						dist.distributions()92							.exactly_one()93							.map_err(|e| anyhow!("{e}"))94							.context(95								"with no machine specified, there should be exactly one distribution",96							)?97					};9899					let part = if let Some(part_name) = &part_name {100						dist.secret.parts.get(part_name).ok_or_else(|| {101							anyhow!("secret {name} does not have part named {part_name}")102						})?103					} else {104						dist.secret105							.parts106							.iter()107							.exactly_one()108							.map_err(|e| anyhow!("{e}"))109							.context("with no part specified, there should be exactly one part")?110							.1111					};112					let owners = dist.owners().cloned().collect::<Vec<_>>();113					let secret_data = part.raw.clone();114					(owners, secret_data)115				};116117				for host in config118					.preferred_hosts(|h| owners.iter().any(|o| o.as_host() == Some(h)))119					.context("failed to list hosts")?120				{121					let host = match host {122						Ok(h) => h,123						Err(e) => {124							warn!("failed to use host: {e}");125							continue;126						}127					};128					match host.decrypt(secret_data.clone()).await {129						Ok(data) => {130							let mut w = stdout();131							w.write_all(&data)?;132							return Ok(());133						}134						Err(e) => warn!("failed to decrypt on {}: {e}", host.name),135					};136				}137				bail!("failed to find suitable decrypting host");138			}139			Secret::List {} => {140				let secrets = config.data.secrets.read().expect("not poisoned");141142				#[derive(tabled::Tabled)]143				struct Row {144					#[tabled(rename = "Name")]145					name: String,146					#[tabled(rename = "Dist")]147					dist: String,148					#[tabled(rename = "Owners")]149					owners: String,150				}151152				let mut rows = Vec::new();153				for name in secrets.keys() {154					let dists = secrets.get(name).unwrap();155					for (idx, dist) in dists.all_distributions().enumerate() {156						let active: Vec<_> = dist157							.owners()158							.filter_map(|o| o.as_host())159							.map(str::to_owned)160							.collect();161						let pruned: Vec<_> = dist162							.owners_pending_prune()163							.filter_map(|o| o.as_host())164							.map(|h| format!("{h} (pruned)"))165							.collect();166						let mut all_owners = active;167						all_owners.extend(pruned);168169						let dist_label = if dist.is_pending_prune() {170							format!("{idx} (pruned)")171						} else {172							idx.to_string()173						};174175						rows.push(Row {176							name: if idx == 0 {177								name.clone()178							} else {179								String::new()180							},181							dist: dist_label,182							owners: all_owners.join("\n"),183						});184					}185				}186187				use tabled::settings::{Style, width::Width};188				let mut table = tabled::Table::new(rows);189				table.with(Width::wrap(80));190				println!("{table}");191			}192			Secret::Prune {193				name,194				machine,195				whole_dist,196			} => {197				Self::prune(config, &name, &machine, whole_dist)?;198			}199			Secret::Ensure { name, machine } => {200				Self::ensure(config, opts, &name, &machine)?;201			}202			Secret::Regenerate { name, machine } => {203				let pruned = Self::prune(config, &name, &machine, true)?;204				// In general, this is not correct - already evaluated secret would still be cached after pruning205				// But as a dedicated CLI subcommand it is safe to assume it was not evaluated yet206				Self::ensure(config, opts, &name, &pruned)?;207			}208		}209		Ok(())210	}211212	fn prune(213		config: &Config,214		name: &str,215		machine: &[String],216		whole_dist: bool,217	) -> Result<Vec<String>> {218		let mut secrets = config.data.secrets.write().expect("not poisoned");219		let Some(dists) = secrets.get_mut(name) else {220			bail!("secret {name} not found");221		};222		let owners_before: BTreeSet<String> = dists223			.owners()224			.filter_map(|o| o.as_host().map(str::to_owned))225			.collect();226227		if machine.is_empty() && whole_dist {228			for dist in dists.distributions_mut() {229				dist.prune("manual prune".to_owned());230			}231		} else if machine.is_empty() {232			let dist = dists233				.distributions_mut()234				.exactly_one()235				.map_err(|e| anyhow!("{e}"))236				.context("with no machine specified, there should be exactly one distribution")?;237			dist.prune("manual prune".to_owned());238		} else if whole_dist {239			for dist in dists.distributions_mut() {240				if machine241					.iter()242					.any(|m| dist.owners().any(|o| o.as_host() == Some(m.as_str())))243				{244					dist.prune(format!(245						"manual prune of distribution containing {}",246						machine.join(", ")247					));248				}249			}250		} else {251			let owners: BTreeSet<SecretOwner> = machine.iter().map(SecretOwner::host).collect();252			for dist in dists.distributions_mut() {253				dist.prune_owners(&owners, "manual prune".to_owned());254			}255		}256257		let owners_after: BTreeSet<String> = dists258			.owners()259			.filter_map(|o| o.as_host().map(str::to_owned))260			.collect();261		Ok(owners_before.difference(&owners_after).cloned().collect())262	}263264	fn ensure(config: &Config, opts: &FleetOpts, name: &str, machine: &[String]) -> Result<()> {265		let hosts: Vec<String> = if machine.is_empty() {266			config267				.list_hosts()?268				.into_iter()269				.filter(|h| opts.should_skip(h).ok() != Some(true))270				.map(|h| h.name)271				.collect()272		} else {273			machine.to_vec()274		};275276		for hostname in &hosts {277			let nixos_cfg = config.system_config(hostname)?;278			let secrets = nix_go!(nixos_cfg.secrets);279			if secrets.has_field(name)? {280				info!("ensuring secret {name} for {hostname}");281				// Force evaluation of secret parts, triggering __fleetEnsureHostSecret282				nix_go!(secrets[{ name }].definition.parts);283			}284		}285		Ok(())286	}287}
modifiedcrates/fleet-base/src/fleetdata.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/fleetdata.rs
+++ b/crates/fleet-base/src/fleetdata.rs
@@ -218,6 +218,12 @@
 	pub fn owners(&self) -> impl Iterator<Item = &SecretOwner> {
 		self.owners_ex(false)
 	}
+	pub fn owners_pending_prune(&self) -> impl Iterator<Item = &SecretOwner> {
+		self.owners_pending_prune.keys()
+	}
+	pub fn is_pending_prune(&self) -> bool {
+		self.pending_prune.is_some()
+	}
 
 	pub fn prune(&mut self, reason: String) {
 		assert!(
@@ -466,6 +472,9 @@
 		}
 	}
 
+	pub fn all_distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {
+		self.stored.iter()
+	}
 	pub fn distributions(&self) -> impl Iterator<Item = &FleetSecretDistribution> {
 		self.stored.iter().filter(|v| v.pending_prune.is_none())
 	}