difftreelog
feat fleet secret prune command implementation
in: trunk
1 file changed
cmds/fleet/src/cmds/secrets.rsdiffbeforeafterboth1use 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*/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}