From b9d34e7614c7b9e9627dcc84fc22427b4387c80b Mon Sep 17 00:00:00 2001 From: Anthony Oteri Date: Mon, 18 Sep 2023 13:54:38 -0400 Subject: [PATCH] Add support for listing image tags --- src/api.rs | 78 ++++++++++++++++++++++++++++++++++++++++ src/cli.rs | 32 ++++++++++++----- src/commands.rs | 1 + src/commands/catalog.rs | 80 +++-------------------------------------- src/commands/tags.rs | 47 ++++++++++++++++++++++++ src/config.rs | 6 ++-- src/error.rs | 1 + src/main.rs | 12 ++++--- 8 files changed, 165 insertions(+), 92 deletions(-) create mode 100644 src/api.rs create mode 100644 src/commands/tags.rs diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..8472d0a --- /dev/null +++ b/src/api.rs @@ -0,0 +1,78 @@ +use serde::Deserialize; + +use crate::config::Config; +use crate::error::ApiError; + +/// Iterate over a paginated result set, collecting and returning the response +/// set. +/// +/// The Docker Registry API specifies that when making a GET request, the +/// response will be paginated using a Link response header for the Next URI. +/// The URL will be encoded using RFC5988. [https://tools.ietf.org/html/rfc5988] +/// +/// This function will continuously request the "Next" link as long as it is +/// returned, collecting and returning the deserialized response bodies as a +/// Vec. +/// +/// # Errors: +/// +/// Returns an `ApiError` if there is a problem constructing the URL from the +/// configured `registry_url` base and the given `path`, or if there is an +/// error deserializing the HTTP response body as JSON, or if there is an +/// error parsing the `Link` header value as an RFC5988 URL. +pub async fn fetch_all Deserialize<'de>>( + config: &Config, + path: &str, +) -> Result, ApiError> { + log::trace!("fetch_all({path:?})"); + + let mut responses: Vec = Vec::default(); + let mut uri = String::from(path); + loop { + log::debug!("GET {uri:?}"); + let url = config.registry_url.join(&uri)?; + + let resp = reqwest::get(url).await?; + let headers = resp.headers().to_owned(); + responses.push(resp.json().await?); + + if let Some(path) = parse_rfc5988(headers.get(http::header::LINK))? { + uri = path; + } else { + break; + } + } + Ok(responses) +} + +/// Given an optional header value possibly containing an RFC5988 formatted +/// URL, parse said URL into a `String`. +/// +/// If the header_value does not contain a correctly formatted RFC5988 URL, +/// or if the header_value is not properly formatted containing a URL +/// surrounded by angle brackets, separated from the link relation by a ';' +/// character, the `None` variant will be returned. +/// +/// # Errors: +/// +/// Returns and `ApiError` if there is a problem parsing contents of the +/// supplied header value. +fn parse_rfc5988(header_value: Option<&http::HeaderValue>) -> Result, ApiError> { + log::trace!("parse_rfc5988({header_value:?})"); + + if let Some(link_value) = header_value { + let link_str = link_value.to_str()?; + let parts: Vec<&str> = link_str.split(';').collect(); + if let Some(url_part) = parts.first() { + if let Some(path) = url_part + .trim() + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + { + return Ok(Some(String::from(path))); + } + } + } + + Ok(None) +} diff --git a/src/cli.rs b/src/cli.rs index fa63c56..322d6bc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,11 +1,12 @@ #![allow(unused_imports)] +use std::ffi::OsString; +use std::path::PathBuf; + use clap::Args; use clap::Parser; use clap::Subcommand; use clap::ValueEnum; -use std::ffi::OsString; -use std::path::PathBuf; /// Dredge is a command line tool for working with the Docker Registry /// V2 API. @@ -21,13 +22,13 @@ pub(crate) struct Cli { pub config: Option, #[arg( - long = "log-level", - require_equals = true, - value_name = "LEVEL", - num_args = 0..=1, - default_value_t = LogLevel::Info, - default_missing_value="info", - value_enum + long = "log-level", + require_equals = true, + value_name = "LEVEL", + num_args = 0..=1, + default_value_t = LogLevel::Info, + default_missing_value = "info", + value_enum )] pub log_level: LogLevel, } @@ -55,11 +56,24 @@ impl From for log::LevelFilter { } } +#[derive(Debug, Args)] +pub struct TagsArgs { + /// The image name. + #[arg( + long, + num_args = 0..=1 + )] + pub(crate) name: String, +} + #[derive(Debug, Subcommand)] pub enum Commands { /// Fetch the list of available repositories from the catalog. Catalog, + /// Fetch the list of tags for a given image. + Tags(TagsArgs), + /// Perform a simple API Version check towards the configured registry /// endpoint. Check, diff --git a/src/commands.rs b/src/commands.rs index f97e78e..3060f6d 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,2 +1,3 @@ pub mod catalog; +pub mod tags; pub mod version; diff --git a/src/commands/catalog.rs b/src/commands/catalog.rs index 794935e..4e757a6 100644 --- a/src/commands/catalog.rs +++ b/src/commands/catalog.rs @@ -3,9 +3,11 @@ //! The "catalog" command works with the Docker Registry API's "catalog" //! entitity available at /v2/_catalog. //! +use serde::Deserialize; + +use crate::api; use crate::config::Config; use crate::error::ApiError; -use serde::Deserialize; /// Path to the Docker Registry API's "catalog" entity. const BASE_CATALOG_URI: &str = "/v2/_catalog"; @@ -27,7 +29,7 @@ pub async fn handler(config: &Config) -> Result<(), ApiError> { log::trace!("handler()"); - let responses: Vec = fetch_all(config, BASE_CATALOG_URI).await?; + let responses: Vec = api::fetch_all(config, BASE_CATALOG_URI).await?; let repository_list: Vec<&str> = responses .iter() .flat_map(|r| r.repositories.iter().map(String::as_str)) @@ -39,77 +41,3 @@ pub async fn handler(config: &Config) -> Result<(), ApiError> { Ok(()) } - -/// Iterate over a paginated result set, collecting and returning the response -/// set. -/// -/// The Docker Registry API specifies that when making a GET request, the -/// response will be paginated using a Link response header for the Next URI. -/// The URL will be encoded using RFC5988. [https://tools.ietf.org/html/rfc5988] -/// -/// This function will continuously request the "Next" link as long as it is -/// returned, collecting and returning the deserialized response bodies as a -/// Vec. -/// -/// # Errors: -/// -/// Returns an `ApiError` if there is a problem constructing the URL from the -/// configured `registry_url` base and the given `path`, or if there is an -/// error deserializing the HTTP response body as JSON, or if there is an -/// error parsing the `Link` header value as an RFC5988 URL. -async fn fetch_all serde::Deserialize<'de>>( - config: &Config, - path: &str, -) -> Result, ApiError> { - log::trace!("fetch_all({path:?})"); - - let mut responses: Vec = Vec::default(); - let mut uri = String::from(path); - loop { - log::debug!("GET {uri:?}"); - let url = config.registry_url.join(&uri)?; - - let resp = reqwest::get(url).await?; - let headers = resp.headers().to_owned(); - responses.push(resp.json().await?); - - if let Some(path) = parse_rfc5988(headers.get(http::header::LINK))? { - uri = path; - } else { - break; - } - } - Ok(responses) -} - -/// Given an optional header value possibly containing an RFC5988 formatted -/// URL, parse said URL into a `String`. -/// -/// If the header_value does not contain a correctly formatted RFC5988 URL, -/// or if the header_value is not properly formatted containing a URL -/// surrounded by angle brackets, separated from the link relation by a ';' -/// character, the `None` variant will be returned. -/// -/// # Errors: -/// -/// Returns and `ApiError` if there is a problem parsing contents of the -/// supplied header value. -fn parse_rfc5988(header_value: Option<&http::HeaderValue>) -> Result, ApiError> { - log::trace!("parse_rfc5988({header_value:?})"); - - if let Some(link_value) = header_value { - let link_str = link_value.to_str()?; - let parts: Vec<&str> = link_str.split(';').collect(); - if let Some(url_part) = parts.first() { - if let Some(path) = url_part - .trim() - .strip_prefix('<') - .and_then(|s| s.strip_suffix('>')) - { - return Ok(Some(String::from(path))); - } - } - } - - Ok(None) -} diff --git a/src/commands/tags.rs b/src/commands/tags.rs new file mode 100644 index 0000000..4ffcf3d --- /dev/null +++ b/src/commands/tags.rs @@ -0,0 +1,47 @@ +//! Command module responsible for handling the "tags" command. +//! +//! The "tags" command works with the Docker Registry API's "tags" +//! entitity available at /v2//tags/list. +//! +use serde::Deserialize; + +use crate::api; +use crate::cli::TagsArgs; +use crate::config::Config; +use crate::error::ApiError; + +/// Path to the Docker Registry API's "catalog" entity. +const BASE_TAGS_URI: &str = "/v2/{name}/tags/list"; + +/// Handler for the `Tags` endpoint +/// +/// Fetch the list of tags names for a given image from the Docker Registry API, and +/// simply print the resulting names to stdout. +/// +/// # Errors: +/// +/// Returns an `ApiError` if there is a problem fetching or parsing the +/// responses from the Docker Registry API. +pub async fn handler(config: &Config, args: &TagsArgs) -> Result<(), ApiError> { + #[derive(Deserialize)] + struct Response { + name: String, + tags: Vec, + } + + log::trace!("handler()"); + + let name = args.name.clone(); + let url = BASE_TAGS_URI.replace("{name}", &name); + let responses: Vec = api::fetch_all(config, &url).await?; + let tag_list: Vec<&str> = responses + .iter() + .flat_map(|r| r.tags.iter().map(String::as_str)) + .collect(); + + for tag in tag_list { + println!("{tag}"); + } + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs index 698672d..0bd612f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,11 @@ -use crate::error::ConfigError; -use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; + +use serde::{Deserialize, Serialize}; use url::Url; +use crate::error::ConfigError; + #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct Config { pub registry_url: Url, diff --git a/src/error.rs b/src/error.rs index 422ee73..f9bc798 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,5 @@ #![allow(clippy::enum_variant_names)] + use thiserror::Error; /// The common error type for this Application. diff --git a/src/main.rs b/src/main.rs index b1b8a62..83eb684 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,15 @@ +use std::ffi::OsString; +use std::path::PathBuf; + +use clap::Parser; + use crate::cli::Cli; use crate::cli::Commands; use crate::config::Config; use crate::error::ConfigError; use crate::error::DredgeError; -use clap::Parser; -use std::ffi::OsString; -use std::path::PathBuf; +mod api; pub(crate) mod cli; mod commands; mod config; @@ -82,11 +85,10 @@ async fn main() -> Result<(), DredgeError> { locate_config_file(args.config).map_or_else(create_default_config_file, Ok)?; log::debug!("Using configuration file {config_file:?}"); - #[allow(unused_variables)] let config = Config::try_from(config_file.as_ref())?; - match args.command { Commands::Catalog => commands::catalog::handler(&config).await?, + Commands::Tags(args) => commands::tags::handler(&config, &args).await?, Commands::Check => commands::version::handler(&config).await?, }