git.delta.rocks / jrsonnet / refs/commits / 48f2bca6ca28

difftreelog

feat fleet secret prune command implementation

owzlypyvYaroslav Bolyukin2026-04-18parent: #c4cd98e.patch.diff
in: trunk

1 file changed

modifiedcmds/fleet/src/cmds/secrets.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets.rs
1use std::io::{Write as _, stdout};23use anyhow::{Context as _, Result, anyhow, bail};4use clap::Parser;5use fleet_base::{fleetdata::SecretOwner, host::Config, opts::FleetOpts};6use itertools::Itertools as _;7use tracing::warn;89#[derive(Parser)]10pub enum Secret {11	/// Force load host keys for all defined hosts12	ForceKeys,13	/// Read secret from remote host, requires sudo on one of the owning hosts14	Read {15		/// Secret name to read16		name: String,1718		/// Distribution with what machine to read19		/// If not shared between multiple - defaults to single owner20		#[clap(short = 'm', long)]21		machine: Option<String>,2223		/// Which private secret part to read24		/// If not specified - only one existing part is read25		#[clap(short = 'p', long)]26		part: Option<String>,27	},28	/// Prune (remove, mark for regeneration) secrets29	Prune {30		/// Secret to prune31		name: String,3233		/// Machines to prune - if specified, only the choosen machines will be pruned34		#[clap(short = 'm', long)]35		machine: Vec<String>,36	},37	/// Ensure secret is generated and not expired38	Ensure {39		/// Secret to ensure generated40		name: String,4142		/// Machines to force secret for43		#[clap(short = 'm', long)]44		machine: Vec<String>,45	},46	List {},47}4849impl Secret {50	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {51		match self {52			Secret::ForceKeys => {53				for host in config.list_hosts()? {54					if opts.should_skip(&host)? {55						continue;56					}57					config.host_key(&host.name).await?;58				}59			}60			Secret::Read {61				name,62				machine,63				part: part_name,64			} => {65				let (owners, secret_data) = {66					let secret = config.data.secrets.read().expect("not poisoned");6768					let Some(dist) = secret.get(&name) else {69						bail!("secret doesn't exists");70					};7172					let dist = if let Some(machine) = &machine {73						dist.get(&SecretOwner::host(machine))74							.ok_or_else(|| anyhow!("machine {machine} has no secret generated"))?75					} else {76						dist.distributions()77							.exactly_one()78							.map_err(|e| anyhow!("{e}"))79							.context(80								"with no machine specified, there should be exactly one distribution",81							)?82					};8384					let part = if let Some(part_name) = &part_name {85						dist.secret.parts.get(part_name).ok_or_else(|| {86							anyhow!("secret {name} does not have part named {part_name}")87						})?88					} else {89						dist.secret90							.parts91							.iter()92							.exactly_one()93							.map_err(|e| anyhow!("{e}"))94							.context("with no part specified, there should be exactly one part")?95							.196					};97					let owners = dist.owners().cloned().collect::<Vec<_>>();98					let secret_data = part.raw.clone();99					(owners, secret_data)100				};101102				for host in config103					.preferred_hosts(|h| owners.iter().any(|o| o.as_host() == Some(h)))104					.context("failed to list hosts")?105				{106					let host = match host {107						Ok(h) => h,108						Err(e) => {109							warn!("failed to use host: {e}");110							continue;111						}112					};113					match host.decrypt(secret_data.clone()).await {114						Ok(data) => {115							let mut w = stdout();116							w.write_all(&data)?;117							return Ok(());118						}119						Err(e) => warn!("failed to decrypt on {}: {e}", host.name),120					};121				}122				bail!("failed to find suitable decrypting host");123			}124			Secret::List {} => {125				/*126				let _span = info_span!("loading secrets").entered();127				let configured = config.list_configured_shared()?;128				#[derive(Tabled)]129				struct SecretDisplay {130					#[tabled(rename = "Name")]131					name: String,132					#[tabled(rename = "Owners")]133					owners: String,134				}135				// let mut table = vec![];136				for name in configured.iter().cloned() {137					let config = config.clone();138					let data = config.shared_secret(&name).expect("exists");139					/*140										let definition = config.shared_secret_definition(&name)?;141										let expectations = definition.expectations()?;142										let owners = data143											.owners()144											.map(|o| {145												if expectations.owners.contains(o) {146													o.green().to_string()147												} else {148													o.red().to_string()149												}150											})151											.collect::<Vec<_>>();152										table.push(SecretDisplay {153											owners: owners.join(", "),154											name,155										})156					*/157				}158				// info!("loaded\n{}", Table::new(table).to_string())159				*/160				todo!()161			}162			Secret::Prune { name, machine } => todo!(),163			Secret::Ensure { name, machine } => todo!(),164		}165		Ok(())166	}167}168169/*170async fn edit_temp_file(171	builder: tempfile::Builder<'_, '_>,172	r: Vec<u8>,173	header: &str,174	comment: &str,175) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {176	if !stdin().is_tty() {177		// TODO: Also try to open /dev/tty directly?178		bail!("stdin is not tty, can't open editor");179	}180181	use std::fmt::Write;182	let mut file = builder.tempfile()?;183184	let mut full_header = String::new();185	let mut had = false;186	for line in header.trim_end().lines() {187		had = true;188		writeln!(&mut full_header, "{comment}{line}")?;189	}190	if had {191		writeln!(&mut full_header, "{}", comment.trim_end())?;192	}193	writeln!(194		&mut full_header,195		"{comment}Do not touch this header! It will be removed automatically"196	)?;197198	file.write_all(full_header.as_bytes())?;199	file.write_all(&r)?;200201	let abs_path = file.into_temp_path();202	let editor = std::env::var_os("VISUAL")203		.or_else(|| std::env::var_os("EDITOR"))204		.unwrap_or_else(|| "vi".into());205	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())206		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;207	let editor_args = editor_args208		.into_iter()209		.map(|v| {210			// Only ASCII subsequences are replaced211			unsafe { OsString::from_encoded_bytes_unchecked(v) }212		})213		.collect_vec();214	let Some((editor, args)) = editor_args.split_first() else {215		bail!("EDITOR env var has no command");216	};217	let mut command = Command::new(editor);218	command.args(args);219220	let path_arg = abs_path.canonicalize()?;221222	// TODO: Save full state, using tcget/_getmode/_setmode223	let was_raw = terminal::is_raw_mode_enabled()?;224	terminal::enable_raw_mode()?;225226	let status = command.arg(path_arg).status().await;227228	if !was_raw {229		terminal::disable_raw_mode()?;230	}231232	let success = match status {233		Ok(s) => s.success(),234		Err(e) if e.kind() == io::ErrorKind::NotFound => {235			bail!("editor not found")236		}237		Err(e) => bail!("editor spawn error: {e}"),238	};239240	let mut file = std::fs::read(&abs_path).context("read editor output")?;241	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {242		todo!();243	};244	todo!();245246	// Ok((success, abs_path))247}248*/
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 tracing::warn;910#[derive(Parser)]11pub enum Secret {12	/// Force load host keys for all defined hosts13	ForceKeys,14	/// Read secret from remote host, requires sudo on one of the owning hosts15	Read {16		/// Secret name to read17		name: String,1819		/// Distribution with what machine to read20		/// If not shared between multiple - defaults to single owner21		#[clap(short = 'm', long)]22		machine: Option<String>,2324		/// Which private secret part to read25		/// If not specified - only one existing part is read26		#[clap(short = 'p', long)]27		part: Option<String>,28	},29	/// Prune (remove, mark for regeneration) secrets30	Prune {31		/// Secret to prune32		name: String,3334		/// Machines to prune - if specified, only the choosen machines will be pruned35		#[clap(short = 'm', long)]36		machine: Vec<String>,3738		/// If set - distributions containing the specified machines will be pruned fully39		#[clap(long)]40		whole_dist: bool,41	},42	/// Ensure secret is generated and not expired43	Ensure {44		/// Secret to ensure generated45		name: String,4647		/// Machines to force secret for48		#[clap(short = 'm', long)]49		machine: Vec<String>,50	},51	List {},52}5354impl Secret {55	pub async fn run(self, config: &Config, opts: &FleetOpts) -> Result<()> {56		match self {57			Secret::ForceKeys => {58				for host in config.list_hosts()? {59					if opts.should_skip(&host)? {60						continue;61					}62					config.host_key(&host.name).await?;63				}64			}65			Secret::Read {66				name,67				machine,68				part: part_name,69			} => {70				let (owners, secret_data) = {71					let secret = config.data.secrets.read().expect("not poisoned");7273					let Some(dist) = secret.get(&name) else {74						bail!("secret doesn't exists");75					};7677					let dist = if let Some(machine) = &machine {78						dist.get(&SecretOwner::host(machine))79							.ok_or_else(|| anyhow!("machine {machine} has no secret generated"))?80					} else {81						dist.distributions()82							.exactly_one()83							.map_err(|e| anyhow!("{e}"))84							.context(85								"with no machine specified, there should be exactly one distribution",86							)?87					};8889					let part = if let Some(part_name) = &part_name {90						dist.secret.parts.get(part_name).ok_or_else(|| {91							anyhow!("secret {name} does not have part named {part_name}")92						})?93					} else {94						dist.secret95							.parts96							.iter()97							.exactly_one()98							.map_err(|e| anyhow!("{e}"))99							.context("with no part specified, there should be exactly one part")?100							.1101					};102					let owners = dist.owners().cloned().collect::<Vec<_>>();103					let secret_data = part.raw.clone();104					(owners, secret_data)105				};106107				for host in config108					.preferred_hosts(|h| owners.iter().any(|o| o.as_host() == Some(h)))109					.context("failed to list hosts")?110				{111					let host = match host {112						Ok(h) => h,113						Err(e) => {114							warn!("failed to use host: {e}");115							continue;116						}117					};118					match host.decrypt(secret_data.clone()).await {119						Ok(data) => {120							let mut w = stdout();121							w.write_all(&data)?;122							return Ok(());123						}124						Err(e) => warn!("failed to decrypt on {}: {e}", host.name),125					};126				}127				bail!("failed to find suitable decrypting host");128			}129			Secret::List {} => {130				/*131				let _span = info_span!("loading secrets").entered();132				let configured = config.list_configured_shared()?;133				#[derive(Tabled)]134				struct SecretDisplay {135					#[tabled(rename = "Name")]136					name: String,137					#[tabled(rename = "Owners")]138					owners: String,139				}140				// let mut table = vec![];141				for name in configured.iter().cloned() {142					let config = config.clone();143					let data = config.shared_secret(&name).expect("exists");144					/*145										let definition = config.shared_secret_definition(&name)?;146										let expectations = definition.expectations()?;147										let owners = data148											.owners()149											.map(|o| {150												if expectations.owners.contains(o) {151													o.green().to_string()152												} else {153													o.red().to_string()154												}155											})156											.collect::<Vec<_>>();157										table.push(SecretDisplay {158											owners: owners.join(", "),159											name,160										})161					*/162				}163				// info!("loaded\n{}", Table::new(table).to_string())164				*/165				todo!()166			}167			Secret::Prune {168				name,169				machine,170				whole_dist,171			} => {172				let mut secrets = config.data.secrets.write().expect("not poisoned");173				let Some(dists) = secrets.get_mut(&name) else {174					bail!("secret {name} not found");175				};176				if machine.is_empty() && whole_dist {177					for dist in dists.distributions_mut() {178						dist.prune("manual prune".to_owned());179					}180				} else if machine.is_empty() {181					let dist = dists182						.distributions_mut()183						.exactly_one()184						.map_err(|e| anyhow!("{e}"))185						.context(186							"with no machine specified, there should be exactly one distribution",187						)?;188					dist.prune("manual prune".to_owned());189				} else if whole_dist {190					for dist in dists.distributions_mut() {191						if machine192							.iter()193							.any(|m| dist.owners().any(|o| o.as_host() == Some(m.as_str())))194						{195							dist.prune(format!(196								"manual prune of distribution containing {}",197								machine.join(", ")198							));199						}200					}201				} else {202					let owners: BTreeSet<SecretOwner> =203						machine.iter().map(SecretOwner::host).collect();204					for dist in dists.distributions_mut() {205						dist.prune_owners(&owners, "manual prune".to_owned());206					}207				}208			}209			Secret::Ensure { name, machine } => todo!(),210		}211		Ok(())212	}213}