difftreelog
feat fleet secret list subcommand
in: trunk
2 files changed
cmds/fleet/src/cmds/secrets.rsdiffbeforeafterboth1use 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}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}crates/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())
}