git.delta.rocks / jrsonnet / refs/commits / 7b7c4bb80b01

difftreelog

feat fleet secret regenerate subcommand

puknpnklYaroslav Bolyukin2026-04-18parent: #204755f.patch.diff
in: trunk

1 file changed

modifiedcmds/fleet/src/cmds/secrets.rsdiffbeforeafterboth
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				/*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}