diff --git a/.gitignore b/.gitignore index 6985cf1..196e176 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + + +# Added by cargo + +/target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cc6a467 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dredge" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +async-std = { version = "1.12.0", features = ["async-attributes", "attributes", "tokio1"] } +clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] } +femme = "2.2.1" +http = "0.2.9" +log = "0.4.20" +reqwest = { version = "0.11.20", features = ["json", "gzip", "multipart", "native-tls-vendored"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_toml = "0.0.1" +thiserror = "1.0.48" +toml = "0.8.0" +url = { version = "2.4.1", features = ["serde"] } +xdg = "2.5.2" diff --git a/LICENSE b/LICENSE index 261eeb9..0ea168d 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2023 Anthony Oteri Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..6971a18 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,70 @@ +#![allow(unused_imports)] + +use clap::Args; +use clap::Parser; +use clap::Subcommand; +use clap::ValueEnum; +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(name = "dredge", version, author)] +#[command(about = "A Docker Registry CLI tool", long_about = None)] +pub(crate) struct Cli { + #[command(subcommand)] + pub command: Commands, + + #[arg(short = 'c', long = "config")] + 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 + )] + pub log_level: LogLevel, +} + +#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, + Off, +} + +impl From for log::LevelFilter { + fn from(lvl: LogLevel) -> Self { + match lvl { + LogLevel::Trace => Self::Trace, + LogLevel::Debug => Self::Debug, + LogLevel::Info => Self::Info, + LogLevel::Warn => Self::Warn, + LogLevel::Error => Self::Error, + LogLevel::Off => Self::Off, + } + } +} + +#[derive(Debug, Subcommand)] +pub enum Commands { + Repo(RepoArgs), +} + +#[derive(Debug, Args)] +pub struct RepoArgs { + + #[command(subcommand)] + pub command: RepoCommands, +} + +#[derive(Debug, Subcommand)] +pub enum RepoCommands { + List, +} \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..35eb3df --- /dev/null +++ b/src/commands.rs @@ -0,0 +1 @@ +pub mod repo; \ No newline at end of file diff --git a/src/commands/repo.rs b/src/commands/repo.rs new file mode 100644 index 0000000..71b328c --- /dev/null +++ b/src/commands/repo.rs @@ -0,0 +1,59 @@ +use crate::cli::{RepoArgs, RepoCommands}; +use crate::config::Config; +use crate::error::ApiError; +use serde::Deserialize; + +const BASE_CATALOG_URI: &str = "/v2/_catalog"; + +pub async fn handler(config: &Config, args: &RepoArgs) -> Result<(), ApiError> { + log::trace!("handler()"); + + match args.command { + RepoCommands::List => handle_list(config, args).await?, + } + + Ok(()) +} + + +#[derive(Deserialize)] +struct CatalogResponse { + repositories: Vec, +} + +async fn handle_list(config: &Config, _args: &RepoArgs) -> Result<(), ApiError> { + log::trace!("handle_list()"); + + let mut url = config.registry_url.join(BASE_CATALOG_URI)?; + let mut responses = Vec::new(); + + loop { + log::debug!("Using url {url}"); + let response = reqwest::get(url.clone()).await?; + let headers = response.headers().to_owned(); + let body: CatalogResponse = response.json().await?; + + responses.push(body); + + if let Some(link_value) = headers.get(http::header::LINK) { + 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(uri) = url_part.trim().strip_prefix('<').and_then(|s| s.strip_suffix('>')) { + url = config.registry_url.join(uri)?; + } + } + } else { + break; + } + } + + let repo_list: Vec<&str> = responses.iter().flat_map(|r| r.repositories.iter().map(String::as_str)).collect(); + + for repo in repo_list { + println!("{repo}"); + } + + Ok(()) + +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..698672d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,28 @@ +use crate::error::ConfigError; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use url::Url; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct Config { + pub registry_url: Url, +} + +impl Default for Config { + fn default() -> Self { + Self { + registry_url: Url::parse("https://localhost:5000").unwrap(), + } + } +} + +impl TryFrom<&Path> for Config { + type Error = ConfigError; + + fn try_from(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + let config: Self = toml::from_str(&contents)?; + Ok(config) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..469e0a0 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,71 @@ +#![allow(clippy::enum_variant_names)] +use thiserror::Error; + +/// The common error type for this Application. +#[derive(Error, Debug)] +pub enum DredgeError { + /// An error related to the configuration of the program. + #[error(transparent)] + ConfigError(#[from] ConfigError), + + /// An error communicating with the Registry API + #[error(transparent)] + ApiError(#[from] ApiError), +} + +/// An error related to the configuration fo the program. +#[derive(Error, Debug)] +pub enum ConfigError { + /// An error parsing the configuration from disk. + #[error("Failed to parse configuration file")] + ParseError(Box), + + /// An error writing the configuration to disk. + #[error("Failed to write configuration data")] + WriteError(Box), + + /// A generic IOError + #[error(transparent)] + IOError(#[from] std::io::Error), +} + +impl From for ConfigError { + fn from(other: toml::ser::Error) -> Self { + Self::WriteError(Box::from(other)) + } +} + +impl From for ConfigError { + fn from(other: toml::de::Error) -> Self { + Self::ParseError(Box::from(other)) + } +} + +impl From for ConfigError { + fn from(other: xdg::BaseDirectoriesError) -> Self { + Self::WriteError(Box::from(other)) + } +} + +/// An error related to the communication with the registry API. +#[derive(Error, Debug)] +pub enum ApiError { + + /// Error parsing a URL + #[error(transparent)] + UrlParseError(#[from] url::ParseError), + + /// Error in HTTP Request + #[error(transparent)] + HttpError(#[from] reqwest::Error), + + #[error("Failed to parse response headers")] + ResponseHeaderParseError(Box), +} + +impl From for ApiError { + + fn from(other: reqwest::header::ToStrError) -> Self { + Self::ResponseHeaderParseError(Box::from(other)) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4be96b0 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,93 @@ +use crate::cli::Cli; +use crate::config::Config; +use crate::error::ConfigError; +use crate::error::DredgeError; +use clap::Parser; +use std::ffi::OsString; +use std::path::PathBuf; +use crate::cli::Commands; + +pub(crate) mod cli; +mod commands; +mod config; +mod error; + +/// The default basename of the main configuration file. +const CONFIG_FILE_NAME: &str = "dredge.toml"; + +/// The XDG directory prefix. +const CONFIG_PREFIX: &str = "dredge"; + +/// Locate the absolute path to the saved configuration file on disk. +/// +/// If given an optional `path` to a configuration file, and that file +/// exists on disk, the absoulte path to that file will be returned. +/// Otherwise, the XDG configuration path will be used. If neither the +/// optional `path` parameter refers to an existing file on disk, nor a +/// suitable configuration file can be located within the XDG configuration +/// path, the `None` variant will be returned. +fn locate_config_file(path: Option) -> Option { + log::trace!("locate_config_file({path:?})"); + + match path { + Some(path) => { + let p = PathBuf::from(path); + log::debug!("Checking if path {p:?} exists"); + p.try_exists().map(|_| Some(p)).unwrap_or(None) + } + None => { + let xdg_dirs = xdg::BaseDirectories::with_prefix(CONFIG_PREFIX).ok()?; + let search_paths: Vec = vec![xdg_dirs.get_config_home()] + .into_iter() + .chain(xdg_dirs.get_config_dirs()) + .collect(); + + log::debug!( + "Searching configuration directories for {CONFIG_FILE_NAME} {search_paths:?}" + ); + xdg_dirs.find_config_file(CONFIG_FILE_NAME) + } + } +} + +/// Attempt to create a default configuration file in the XDG configuration +/// path. Any sub-directories of the XDG configuration path which do not +/// already exist will be created automatically. +/// +/// # Errors: +/// +/// This returns a `ConfigError` if a problem occured which prevented either +/// the creation of the directory tree, or in writing the default configuration +/// to the file. +fn create_default_config_file() -> Result { + log::trace!("create_default_config_file()"); + + let xdg_dirs = xdg::BaseDirectories::with_prefix(CONFIG_PREFIX)?; + let config_path = xdg_dirs.place_config_file(CONFIG_FILE_NAME)?; + let default_config = toml::to_string_pretty(&Config::default())?; + std::fs::write(&config_path, default_config)?; + Ok(config_path) +} + +#[async_std::main] +async fn main() -> Result<(), DredgeError> { + let args = Cli::parse(); + + // -- Initialize logging + let log_level = args.log_level.clone(); + femme::with_level(log::LevelFilter::from(log_level)); + + // -- Load and parse configuration file + let config_file = + 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::Repo(repo_args) => commands::repo::handler(&config, &repo_args).await?, + } + + Ok(()) +} \ No newline at end of file