use super::{elapsed, models, VAULT_ENV_NAME};
use crate::{
commands::{map_tags, AttributeArgs, IsDefault, OutputFormat},
credential, TableExt,
};
use akv_cli::{
json,
parsing::{parse_key_value, parse_key_value_opt},
Result,
};
use azure_core::{http::Url, time::OffsetDateTime};
use azure_security_keyvault_secrets::{
models::{
Secret, SecretAttributes, SecretClientGetSecretOptions,
SecretClientUpdateSecretPropertiesOptions, SecretProperties, SetSecretParameters,
UpdateSecretPropertiesParameters,
},
ResourceExt, ResourceId, SecretClient,
};
use clap::{ArgGroup, Subcommand};
use futures::{future, TryStreamExt as _};
use prettytable::{color, format, Attr, Cell, Row, Table};
use timeago::Formatter;
use tracing::{Level, Span};
#[derive(Debug, Subcommand)]
pub enum Commands {
Create {
#[arg(value_name = "NAME=VALUE", value_parser = parse_key_value::<String>)]
secret: (String, String),
#[arg(long, value_name = "URL", env = VAULT_ENV_NAME)]
vault: Url,
#[arg(long, default_value = "text/plain")]
content_type: Option<String>,
#[command(flatten)]
attributes: AttributeArgs,
#[arg(long, value_name = "NAME[=VALUE]", value_parser = parse_key_value_opt::<String>)]
tags: Vec<(String, Option<String>)>,
#[arg(short = 'o', long, value_enum, default_value_t)]
output: OutputFormat,
},
#[command(group(ArgGroup::new("ident").args(&["id", "name"]).required(true)))]
Edit {
#[arg(value_name = "URL", conflicts_with_all = ["name", "version"])]
id: Option<Url>,
#[arg(long, requires = "vault")]
name: Option<String>,
#[arg(long, value_name = "URL", env = VAULT_ENV_NAME)]
vault: Option<Url>,
#[arg(long, requires = "name")]
version: Option<String>,
#[arg(long)]
content_type: Option<String>,
#[command(flatten)]
attributes: AttributeArgs,
#[arg(long, value_name = "NAME[=VALUE]", value_parser = parse_key_value_opt::<String>)]
tags: Vec<(String, Option<String>)>,
#[arg(short = 'o', long, value_enum, default_value_t)]
output: OutputFormat,
},
#[command(group(ArgGroup::new("ident").args(&["id", "name"]).required(true)))]
Get {
#[arg(value_name = "URL", conflicts_with_all = ["name", "version"])]
id: Option<Url>,
#[arg(long, requires = "vault")]
name: Option<String>,
#[arg(long, value_name = "URL", env = VAULT_ENV_NAME)]
vault: Option<Url>,
#[arg(long, requires = "name")]
version: Option<String>,
#[arg(short = 'o', long, value_enum, default_value_t)]
output: OutputFormat,
},
List {
#[arg(long, value_name = "URL", env = VAULT_ENV_NAME)]
vault: Url,
#[arg(long)]
long: bool,
#[arg(long)]
include_managed: bool,
#[arg(short = 'o', long, value_enum, default_value_t)]
output: OutputFormat,
},
#[command(group(ArgGroup::new("ident").args(&["id", "name"]).required(true)))]
ListVersions {
#[arg(value_name = "URL")]
id: Option<Url>,
#[arg(long, requires = "vault")]
name: Option<String>,
#[arg(long, value_name = "URL", env = VAULT_ENV_NAME)]
vault: Option<Url>,
#[arg(long)]
long: bool,
#[arg(short = 'o', long, value_enum, default_value_t)]
output: OutputFormat,
},
}
impl Commands {
pub async fn handle(&self, global_args: &crate::Args) -> Result<()> {
match &self {
Commands::Create { .. } => self.create(global_args).await,
Commands::Edit { .. } => self.edit(global_args).await,
Commands::Get { .. } => self.get(global_args).await,
Commands::List { .. } => self.list(global_args).await,
Commands::ListVersions { .. } => self.list_versions(global_args).await,
}
}
#[tracing::instrument(level = Level::INFO, skip(self, global_args), fields(vault, name), err)]
async fn create(&self, global_args: &crate::Args) -> Result<()> {
let Commands::Create {
secret: (name, value),
vault,
content_type,
attributes:
AttributeArgs {
enabled,
expires,
not_before,
},
tags,
output,
} = self
else {
panic!("invalid command");
};
let current = Span::current();
current.record("vault", vault.as_str());
current.record("name", name);
let client = SecretClient::new(vault.as_str(), credential()?, None)?;
let secret_attributes = SecretAttributes {
enabled: Some(*enabled),
expires: *expires,
not_before: *not_before,
..Default::default()
};
let params = SetSecretParameters {
value: Some(value.to_string()),
content_type: content_type.clone(),
tags: map_tags(tags),
secret_attributes: secret_attributes.default_or(),
};
let secret = client
.set_secret(name, params.try_into()?, None)
.await?
.into_model()?;
match output {
OutputFormat::Json => json::print(&models::Secret::from(secret), global_args.color()),
OutputFormat::Default => show(&secret),
}
}
#[tracing::instrument(level = Level::INFO, skip(self, global_args), fields(vault, name, version), err)]
async fn edit(&self, global_args: &crate::Args) -> Result<()> {
let Commands::Edit {
id,
vault,
name,
version,
content_type,
attributes:
AttributeArgs {
enabled,
expires,
not_before,
},
tags,
output,
} = self
else {
panic!("invalid command");
};
let (vault, name, version) =
super::select(id.as_ref(), vault.as_ref(), name.as_ref(), version.as_ref())?;
let current = Span::current();
current.record("vault", &*vault);
current.record("name", &*name);
current.record("version", version.as_deref());
let client = SecretClient::new(&vault, credential()?, None)?;
let secret_attributes = SecretAttributes {
enabled: Some(*enabled),
expires: *expires,
not_before: *not_before,
..Default::default()
};
let params = UpdateSecretPropertiesParameters {
content_type: content_type.clone(),
tags: map_tags(tags),
secret_attributes: secret_attributes.default_or(),
};
let secret = client
.update_secret_properties(
&name,
params.try_into()?,
Some(SecretClientUpdateSecretPropertiesOptions {
secret_version: version.map(Into::into),
..Default::default()
}),
)
.await?
.into_model()?;
match output {
OutputFormat::Json => json::print(&models::Secret::from(secret), global_args.color()),
OutputFormat::Default => show(&secret),
}
}
#[tracing::instrument(level = Level::INFO, skip(self, global_args), fields(vault, name, version), err)]
async fn get(&self, global_args: &crate::Args) -> Result<()> {
let Commands::Get {
id,
name,
vault,
version,
output,
} = self
else {
panic!("invalid command");
};
let (vault, name, version) =
super::select(id.as_ref(), vault.as_ref(), name.as_ref(), version.as_ref())?;
let current = Span::current();
current.record("vault", &*vault);
current.record("name", &*name);
current.record("version", version.as_deref());
let client = SecretClient::new(&vault, credential()?, None)?;
let secret = client
.get_secret(
&name,
Some(SecretClientGetSecretOptions {
secret_version: version.map(Into::into),
..Default::default()
}),
)
.await?
.into_model()?;
match output {
OutputFormat::Json => json::print(&models::Secret::from(secret), global_args.color()),
OutputFormat::Default => show(&secret),
}
}
#[tracing::instrument(level = Level::INFO, skip(self), fields(vault), err)]
async fn list(&self, global_args: &crate::Args) -> Result<()> {
let Commands::List {
vault,
long,
include_managed,
output,
} = self
else {
panic!("invalid command");
};
Span::current().record("vault", vault.as_str());
let client = SecretClient::new(vault.as_str(), credential()?, None)?;
let mut secrets: Vec<SecretProperties> = client
.list_secret_properties(None)?
.try_filter(|p| future::ready(*include_managed || !p.managed.unwrap_or_default()))
.try_collect()
.await?;
secrets.sort_by(|a, b| a.id.cmp(&b.id));
if matches!(output, OutputFormat::Json) {
return json::print(&secrets, global_args.color());
}
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
let mut titles = Row::new(vec![
Cell::new("NAME").with_style(Attr::Dim),
Cell::new("ID").with_style(Attr::Dim),
Cell::new("TYPE").with_style(Attr::Dim),
]);
if *long {
titles.add_cell(Cell::new("CREATED").with_style(Attr::Dim));
}
titles.add_cell(Cell::new("EDITED").with_style(Attr::Dim));
table.set_titles(titles);
let now = OffsetDateTime::now_utc();
let formatter = Formatter::new();
let name_attr = Attr::ForegroundColor(color::GREEN);
for secret in &secrets {
let resource: ResourceId = secret.resource_id()?;
let source_id = resource.source_id;
let r#type = secret.content_type.as_deref().unwrap_or_default();
let mut row = Row::new(vec![
Cell::new(resource.name.as_str()).with_style(name_attr),
Cell::new(source_id.as_str()),
Cell::new(r#type),
]);
if *long {
let created = elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.created),
);
row.add_cell(Cell::new(created.as_str()));
}
let edited = elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.updated),
);
row.add_cell(Cell::new(edited.as_str()));
table.add_row(row);
}
table.print_color_conditionally(global_args.color())?;
Ok(())
}
#[tracing::instrument(level = Level::INFO, skip(self), fields(vault, name, version), err)]
async fn list_versions(&self, global_args: &crate::Args) -> Result<()> {
let Commands::ListVersions {
id,
name,
vault,
long,
output,
} = self
else {
panic!("invalid command");
};
let (vault, name, version) =
super::select(id.as_ref(), vault.as_ref(), name.as_ref(), None)?;
let current = Span::current();
current.record("vault", &*vault);
current.record("name", &*name);
current.record("version", version.as_deref());
let client = SecretClient::new(&vault, credential()?, None)?;
let mut secrets: Vec<SecretProperties> = client
.list_secret_properties_versions(&name, None)?
.try_collect()
.await?;
secrets.sort_by(|a, b| {
let a = a.attributes.as_ref().and_then(|x| x.updated);
let b = b.attributes.as_ref().and_then(|x| x.updated);
a.cmp(&b).reverse()
});
if matches!(output, OutputFormat::Json) {
return json::print(&secrets, global_args.color());
}
let mut table = Table::new();
table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
let mut titles = Row::new(vec![
Cell::new("ID").with_style(Attr::Dim),
Cell::new("TYPE").with_style(Attr::Dim),
]);
if *long {
titles.add_cell(Cell::new("CREATED").with_style(Attr::Dim));
}
titles.add_cell(Cell::new("EDITED").with_style(Attr::Dim));
table.set_titles(titles);
let now = OffsetDateTime::now_utc();
let formatter = Formatter::new();
let id_attr = Attr::ForegroundColor(color::GREEN);
for secret in &secrets {
let resource: ResourceId = secret.resource_id()?;
let source_id = resource.source_id;
let r#type = secret.content_type.as_deref().unwrap_or_default();
let mut row = Row::new(vec![
Cell::new(source_id.as_str()).with_style(id_attr),
Cell::new(r#type),
]);
if *long {
let created = elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.created),
);
row.add_cell(Cell::new(created.as_str()));
}
let edited = elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.updated),
);
row.add_cell(Cell::new(edited.as_str()));
table.add_row(row);
}
table.print_color_conditionally(global_args.color())?;
Ok(())
}
}
fn show(secret: &Secret) -> Result<()> {
let resource = secret.resource_id()?;
let now = OffsetDateTime::now_utc();
let formatter = Formatter::new();
println!("ID: {}", &resource.source_id);
println!("Name: {}", &resource.name);
println!("Version: {}", resource.version.unwrap_or_default());
println!(
"Type: {}",
secret
.content_type
.as_ref()
.map_or_else(String::new, |s| s.into()),
);
println!(
"Enabled: {}",
secret
.attributes
.as_ref()
.and_then(|attr| attr.enabled)
.unwrap_or_default()
);
println!("Managed: {}", secret.managed.unwrap_or_default());
println!(
"Created: {}",
elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.created)
)
);
println!(
"Edited: {}",
elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.updated)
)
);
println!(
"Not before: {}",
elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.not_before)
)
);
println!(
"Expires: {}",
elapsed(
&formatter,
now,
secret.attributes.as_ref().and_then(|attr| attr.expires)
)
);
println!("Tags:");
if let Some(tags) = &secret.tags {
for (k, v) in tags {
println!(" {k}: {v}");
}
}
Ok(())
}
impl IsDefault for SecretAttributes {
fn is_default(&self) -> bool {
self.enabled.is_none() && self.expires.is_none() && self.not_before.is_none()
}
}