#![forbid(unsafe_code)]
#![deny(
absolute_paths_not_starting_with_crate,
clippy::dbg_macro,
clippy::debug_assert_with_mut_call,
clippy::doc_link_with_quotes,
clippy::doc_markdown,
clippy::empty_line_after_outer_attr,
clippy::empty_structs_with_brackets,
clippy::expect_used,
clippy::float_cmp,
clippy::float_cmp_const,
clippy::float_equality_without_abs,
clippy::indexing_slicing,
clippy::manual_assert,
clippy::missing_const_for_fn,
clippy::missing_docs_in_private_items,
clippy::missing_errors_doc,
clippy::missing_panics_doc,
clippy::option_if_let_else,
clippy::panic,
clippy::print_stderr,
clippy::semicolon_if_nothing_returned,
clippy::shadow_unrelated,
clippy::similar_names,
clippy::suspicious_operation_groupings,
clippy::unseparated_literal_suffix,
clippy::unused_self,
clippy::unwrap_used,
clippy::use_debug,
clippy::used_underscore_binding,
clippy::useless_let_if_seq,
clippy::wildcard_dependencies,
clippy::wildcard_imports,
dead_code,
deprecated,
deprecated_in_future,
exported_private_dependencies,
future_incompatible,
invalid_doc_attributes,
keyword_idents,
macro_use_extern_crate,
missing_debug_implementations,
missing_docs,
non_ascii_idents,
nonstandard_style,
noop_method_call,
trivial_bounds,
trivial_casts,
unreachable_code,
unreachable_patterns,
unreachable_pub,
unused_extern_crates,
unused_import_braces
)]
use clap::{Parser, Subcommand, ValueEnum};
use cli_table::{print_stdout, Cell, CellStruct, Style, Table};
use freta::{
argparse::parse_key_val,
models::webhooks::{WebhookEventId, WebhookEventType, WebhookId},
Client, ClientId, Config, Error, ImageFormat, ImageId, ImageState, OwnerId, Result, Secret,
};
use futures::{future::try_join_all, Stream, StreamExt};
use serde::ser::{SerializeSeq, Serializer};
use serde_json::{ser::PrettyFormatter, Value};
use std::{
fmt::{Display, Formatter},
io::{stderr, stdout},
path::PathBuf,
pin::Pin,
};
use tokio::io::{self, AsyncWriteExt};
use tracing::{info, level_filters::LevelFilter};
use tracing_subscriber::EnvFilter;
use url::Url;
const LICENSES: &str = include_str!(concat!(env!("OUT_DIR"), "/licenses.json"));
const IMAGE_LIST_FIELDS: &[&str] = &["image_id", "owner_id", "state", "format"];
#[derive(Parser)]
#[clap(version, author, about = Some("Project Freta client"))]
struct Args {
#[command(subcommand)]
subcommand: SubCommands,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum OutputFormat {
Json,
Table,
Csv,
}
impl Display for OutputFormat {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Json => write!(f, "json"),
OutputFormat::Table => write!(f, "table"),
OutputFormat::Csv => write!(f, "csv"),
}
}
}
#[derive(Subcommand)]
enum SubCommands {
Eula {
#[clap(subcommand)]
subcommands: EulaCommands,
},
Config {
#[clap(subcommand)]
subcommands: ConfigCommands,
},
Login,
Logout,
Licenses,
Info,
Images {
#[clap(subcommand)]
subcommands: ImagesCommands,
},
Artifacts {
#[clap(subcommand)]
subcommands: ArtifactsCommands,
},
Webhooks {
#[clap(subcommand)]
subcommands: WebhooksCommands,
},
}
#[derive(Subcommand)]
enum EulaCommands {
Get,
Accept,
Reject,
}
#[derive(Subcommand)]
enum ArtifactsCommands {
List {
image_id: ImageId,
#[arg(long, default_value_t=OutputFormat::Json)]
output: OutputFormat,
},
Get {
image_id: ImageId,
path: String,
#[clap(long)]
output: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum WebhooksCommands {
Create {
url: Url,
#[clap(required = true)]
event_types: Vec<WebhookEventType>,
#[clap(long)]
hmac_token: Option<Secret>,
},
Delete {
webhook_id: WebhookId,
},
Get {
webhook_id: WebhookId,
},
Update {
webhook_id: WebhookId,
url: Url,
#[clap(required = true)]
event_types: Vec<WebhookEventType>,
#[clap(long)]
hmac_token: Option<Secret>,
},
List {
#[arg(long, default_value_t=OutputFormat::Json)]
output: OutputFormat,
},
Logs {
webhook_id: WebhookId,
#[arg(long, default_value_t=OutputFormat::Json)]
output: OutputFormat,
},
Ping {
webhook_id: WebhookId,
},
Resend {
webhook_id: WebhookId,
webhook_event_id: WebhookEventId,
},
}
#[derive(Subcommand)]
enum ImagesCommands {
Get {
image_id: ImageId,
},
Monitor {
#[arg(required = true)]
image_ids: Vec<ImageId>,
},
Delete {
#[arg(required = true)]
image_ids: Vec<ImageId>,
},
Reanalyze {
#[arg(required = true)]
image_ids: Vec<ImageId>,
},
List {
#[arg(long)]
image_id: Option<ImageId>,
#[arg(long)]
owner_id: Option<OwnerId>,
#[arg(long)]
state: Option<ImageState>,
#[arg(long)]
include_samples: bool,
#[arg(long, default_value_t=OutputFormat::Json)]
output: OutputFormat,
#[arg(long, action = clap::ArgAction::Append)]
fields: Option<Vec<String>>,
},
Create {
format: ImageFormat,
#[clap(long, value_name = "KEY=VALUE", value_parser = parse_key_val::<String, String>, action = clap::ArgAction::Append)]
tags: Option<Vec<(String, String)>>,
},
Upload {
path: PathBuf,
#[clap(long)]
format: Option<ImageFormat>,
#[clap(long)]
monitor: bool,
#[clap(long)]
show_result: bool,
#[clap(long, value_name = "KEY=VALUE", value_parser = parse_key_val::<String, String>, action = clap::ArgAction::Append)]
tags: Option<Vec<(String, String)>>,
},
Update {
image_id: ImageId,
#[clap(long)]
shareable: Option<bool>,
#[clap(long, value_name = "KEY=VALUE", value_parser = parse_key_val::<String, String>, action = clap::ArgAction::Append)]
tags: Option<Vec<(String, String)>>,
},
Download {
image_id: ImageId,
path: PathBuf,
},
}
#[derive(Subcommand)]
enum ConfigCommands {
Reset,
Get,
Update {
#[clap(long)]
tenant_id: Option<String>,
#[clap(long)]
client_id: Option<String>,
#[clap(long)]
client_secret: Option<String>,
#[clap(long)]
api_url: Option<Url>,
#[clap(long)]
scope: Option<String>,
#[clap(long)]
ignore_login_cache: Option<bool>,
},
}
async fn config(subcommands: ConfigCommands) -> Result<()> {
let config = match subcommands {
ConfigCommands::Reset => {
let config = Config::default();
config.save().await?;
info!("config reset");
config
}
ConfigCommands::Get => Config::load().await?,
ConfigCommands::Update {
tenant_id,
client_id,
client_secret,
api_url,
scope,
ignore_login_cache,
} => {
let mut config = Config::load().await?;
if let Some(tenant_id) = tenant_id {
config.tenant_id = tenant_id;
}
if let Some(api_url) = api_url {
config.api_url = api_url;
}
if let Some(client_id) = client_id {
config.client_id = ClientId::new(client_id);
}
if let Some(scope) = scope {
if scope.is_empty() {
config.scope = None;
} else {
config.scope = Some(scope);
}
}
if let Some(client_secret) = client_secret {
if client_secret.is_empty() {
config.client_secret = None;
} else {
config.client_secret = Some(Secret::new(client_secret));
}
}
if let Some(ignore_login_cache) = ignore_login_cache {
config.ignore_login_cache = ignore_login_cache;
}
config.save().await?;
info!("config updated");
config
}
};
println!("{config}");
Ok(())
}
async fn artifacts(subcommands: ArtifactsCommands) -> Result<()> {
let client = Client::new().await?;
match subcommands {
ArtifactsCommands::List { image_id, output } => {
let stream = client.artifacts_list(image_id);
serialize_stream(output, None, None, stream).await
}
ArtifactsCommands::Get {
image_id,
path,
output,
} => {
if let Some(output) = &output {
client.artifacts_download(image_id, path, output).await
} else {
let blob = client.artifacts_get(image_id, path).await?;
write_stdout(&blob).await?;
Ok(())
}
}
}
}
async fn images(subcommands: ImagesCommands) -> Result<()> {
let client = Client::new().await?;
match subcommands {
ImagesCommands::Get { image_id } => client.images_get(image_id).await.map(print_data)?,
ImagesCommands::List {
image_id,
owner_id,
state,
include_samples,
output,
fields,
} => {
let stream = client.images_list(image_id, owner_id, state, include_samples);
let fields = fields.unwrap_or(
IMAGE_LIST_FIELDS
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
);
serialize_stream(output, Some(fields), Some(("{\"images\":", "}")), stream).await
}
ImagesCommands::Delete { image_ids } => {
let mut result = vec![];
for image_id in image_ids {
result.push(client.images_delete(image_id).await?);
}
print_data(result)
}
ImagesCommands::Reanalyze { image_ids } => {
let mut result = vec![];
for image_id in image_ids {
result.push(client.images_reanalyze(image_id).await?);
}
print_data(result)
}
ImagesCommands::Create { format, tags } => client
.images_create(format, tags.unwrap_or_default())
.await
.map(print_data)?,
ImagesCommands::Update {
image_id,
tags,
shareable,
} => client
.images_update(image_id, tags, shareable)
.await
.map(print_data)?,
ImagesCommands::Upload {
path,
format,
tags,
monitor,
show_result,
} => {
let format = if let Some(format) = format {
format
} else if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
let ignore_case = true;
ImageFormat::from_str(&ext_str, ignore_case)
.map_err(|_| Error::Extension(ext_str.into()))?
} else {
return Err(Error::Extension("missing file extension".into()));
};
let image = client
.images_upload(format, tags.unwrap_or_default(), &path)
.await?;
if monitor || show_result {
client.images_monitor(image.image_id).await?;
}
if show_result {
let result = client.artifacts_get(image.image_id, "report.json").await?;
write_stdout(&result).await?;
}
Ok(())
}
ImagesCommands::Download { image_id, path } => client.images_download(image_id, path).await,
ImagesCommands::Monitor { image_ids } => {
try_join_all(
image_ids
.into_iter()
.map(|image_id| client.images_monitor(image_id)),
)
.await?;
Ok(())
}
}
}
async fn write_stdout(data: &[u8]) -> Result<()> {
io::stdout().write_all(data).await.map_err(|e| Error::Io {
message: "writing to stdout".into(),
source: e,
})
}
async fn eula(opts: EulaCommands) -> Result<()> {
let client = Client::new().await?;
match opts {
EulaCommands::Get => {
let eula = client.eula().await?;
write_stdout(&eula).await?;
}
EulaCommands::Accept => {
let info = client.info().await?;
let config = client.user_config_get().await?;
client
.user_config_update(Some(info.current_eula), config.include_samples)
.await?;
}
EulaCommands::Reject => {
let config = client.user_config_get().await?;
client
.user_config_update(None, config.include_samples)
.await?;
}
}
Ok(())
}
async fn info() -> Result<()> {
let client = Client::new().await?;
let info = client.info().await?;
let as_str = serde_json::to_string_pretty(&info)?;
println!("{as_str}");
Ok(())
}
async fn webhooks(subcommands: WebhooksCommands) -> Result<()> {
let client = Client::new().await?;
match subcommands {
WebhooksCommands::Create {
url,
event_types,
hmac_token,
} => client
.webhook_create(url, event_types.into_iter().collect(), hmac_token)
.await
.map(print_data)?,
WebhooksCommands::Delete { webhook_id } => {
client.webhook_delete(webhook_id).await.map(print_data)?
}
WebhooksCommands::Get { webhook_id } => {
client.webhook_get(webhook_id).await.map(print_data)?
}
WebhooksCommands::Ping { webhook_id } => {
let result = client.webhook_ping(webhook_id).await?;
write_stdout(&result).await?;
Ok(())
}
WebhooksCommands::Update {
webhook_id,
url,
event_types,
hmac_token,
} => client
.webhook_update(
webhook_id,
url,
event_types.into_iter().collect(),
hmac_token,
)
.await
.map(print_data)?,
WebhooksCommands::List { output } => {
let stream = client.webhooks_list();
serialize_stream(output, None, Some(("{\"webhooks\":", "}")), stream).await
}
WebhooksCommands::Logs { webhook_id, output } => {
let stream = client.webhooks_logs(webhook_id);
serialize_stream(output, None, Some(("{\"webhook_events\":", "}")), stream).await
}
WebhooksCommands::Resend {
webhook_id,
webhook_event_id,
} => client
.webhook_resend(webhook_id, webhook_event_id)
.await
.map(print_data)?,
}
}
fn print_data<D>(data: D) -> Result<()>
where
D: serde::Serialize,
{
serde_json::to_writer_pretty(stdout(), &data)?;
Ok(())
}
fn to_cell(value: &Value) -> Result<CellStruct> {
let as_cell = match value {
Value::String(s) => s.cell(),
Value::Number(n) => n.to_string().cell(),
Value::Bool(b) => b.to_string().cell(),
Value::Null => "null".cell(),
Value::Array(_) | Value::Object(_) => serde_json::to_string(value)?.cell(),
};
Ok(as_cell)
}
async fn table_serialize_stream<V>(
fields: Option<Vec<String>>,
mut stream: Pin<Box<impl Stream<Item = std::result::Result<V, crate::Error>>>>,
) -> Result<()>
where
V: serde::Serialize,
{
let mut table: Vec<Vec<CellStruct>> = Vec::new();
let mut title = vec![];
let mut have_title = false;
while let Some(entry) = stream.next().await {
let entry = entry?;
let entry = serde_json::to_value(entry)?;
if let Some(obj) = entry.as_object() {
let mut row = vec![];
for (key, value) in obj {
if !fields.as_ref().map_or(true, |y| y.contains(key)) {
continue;
}
if !have_title {
title.push(key.cell().bold(true));
}
row.push(to_cell(value)?);
}
have_title = true;
table.push(row);
} else {
table.push(vec![to_cell(&entry)?]);
}
}
let table = table.table().title(title).bold(true);
print_stdout(table).map_err(|e| Error::Io {
message: "writing result table".into(),
source: e,
})?;
Ok(())
}
async fn csv_serialize_stream<V>(
fields: Option<Vec<String>>,
mut stream: Pin<Box<impl Stream<Item = std::result::Result<V, crate::Error>>>>,
) -> Result<()>
where
V: serde::Serialize,
{
let mut ser = csv::Writer::from_writer(std::io::stdout());
let mut wrote_headers = false;
while let Some(entry) = stream.next().await {
let entry = entry?;
let mut entry = serde_json::to_value(entry)?;
if let Some(obj) = entry.as_object_mut() {
obj.retain(|key, _| fields.as_ref().map_or(true, |y| y.contains(key)));
if !wrote_headers {
let headers = obj.keys().collect::<Vec<_>>();
ser.write_record(headers)?;
wrote_headers = true;
}
let mut values = vec![];
for (_, value) in &mut *obj {
if value.is_object() || value.is_array() {
*value = serde_json::Value::String(serde_json::to_string(value)?);
}
values.push(value);
}
ser.serialize(values)?;
} else {
ser.serialize(&entry)?;
}
}
Ok(())
}
async fn json_serialize_stream<V>(
wrapper: Option<(&str, &str)>,
mut stream: Pin<Box<impl Stream<Item = std::result::Result<V, crate::Error>>>>,
) -> Result<()>
where
V: serde::Serialize,
{
if let Some((prefix, _)) = &wrapper {
print!("{prefix}");
}
let mut ser = serde_json::Serializer::with_formatter(std::io::stdout(), PrettyFormatter::new());
let mut serializer = ser.serialize_seq(None)?;
while let Some(entry) = stream.next().await {
let entry = entry?;
serializer.serialize_element(&entry)?;
}
serializer.end()?;
if let Some((_, suffix)) = &wrapper {
print!("{suffix}");
}
Ok(())
}
async fn serialize_stream<V>(
output: OutputFormat,
fields: Option<Vec<String>>,
wrapper: Option<(&str, &str)>,
stream: Pin<Box<impl Stream<Item = std::result::Result<V, crate::Error>>>>,
) -> Result<()>
where
V: serde::Serialize,
{
match output {
OutputFormat::Table => table_serialize_stream(fields, stream).await,
OutputFormat::Csv => csv_serialize_stream(fields, stream).await,
OutputFormat::Json => json_serialize_stream(wrapper, stream).await,
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.from_env()
.map_err(|e| Error::Other("invalid env filter", e.to_string()))?,
)
.with_writer(stderr)
.init();
let cmd = Args::parse();
match cmd.subcommand {
SubCommands::Config { subcommands } => {
config(subcommands).await?;
}
SubCommands::Login => {
Client::new().await?;
}
SubCommands::Logout => {
Client::logout().await?;
}
SubCommands::Info => {
info().await?;
}
SubCommands::Images { subcommands } => {
images(subcommands).await?;
}
SubCommands::Artifacts { subcommands } => {
artifacts(subcommands).await?;
}
SubCommands::Webhooks { subcommands } => {
webhooks(subcommands).await?;
}
SubCommands::Eula { subcommands } => {
eula(subcommands).await?;
}
SubCommands::Licenses => {
println!("{LICENSES}");
}
};
Ok(())
}