diff --git a/Cargo.lock b/Cargo.lock index e152190..c80893d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1126,6 +1126,8 @@ dependencies = [ "axum", "log", "oci-client", + "regex", + "semver", "serde", "serde_json", "tokio", @@ -1316,6 +1318,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "serde" version = "1.0.219" diff --git a/Cargo.toml b/Cargo.toml index 09da5e8..487d4e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,5 @@ tower-http = { version = "0.6", features = ["trace"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing = "0.1" log = "0.4" +semver = "1.0" +regex = "1.1" diff --git a/client/client b/client/client index 542fe86..7272525 100755 --- a/client/client +++ b/client/client @@ -1,8 +1,10 @@ #!/bin/env python import sys +import json import logging import yaml +import argparse from requests import Session @@ -10,9 +12,11 @@ from requests import Session base_url = "http://127.0.0.1:3000/api" -def get_events(s, image): +def get_events(s, image, include, exclude): data = { "image": image, + "include": include if include is not None else [], + "exclude": exclude if exclude is not None else [], } try: r = s.post(base_url + "/tags", json=data) @@ -30,13 +34,25 @@ def main(): datefmt="%Y-%m-%d %H:%M:%S", # Explicit date format ) + # Initialize parser + parser = argparse.ArgumentParser( + prog='rdockup-client', + description='Perform requests against rdockup REST API', + ) + parser.add_argument('-I', '--image', required=True) + parser.add_argument('-i', '--include', nargs='+') + parser.add_argument('-e', '--exclude', nargs='+') + args = parser.parse_args() + # Import config from file with open("./config.yaml", "r", encoding="utf8") as file: config = yaml.safe_load(file) token = config["token"] # Read command line argument - image = sys.argv[1] + image = args.image + include = args.include + exclude = args.exclude s = Session() s.headers.update( @@ -46,7 +62,11 @@ def main(): } ) - print(get_events(s, image)) + result = get_events(s, image, include, exclude) + try: + print(json.dumps(json.loads(result), indent=2)) + except Exception: + print(result) if __name__ == "__main__": main() diff --git a/src/api.rs b/src/api.rs index 0141b7d..b4b80aa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,6 +12,8 @@ use crate::registry_client; #[derive(Deserialize)] struct TagRequest { image: String, + include: Option>, + exclude: Option>, } fn validate_token(token: &str) -> bool { @@ -41,7 +43,13 @@ async fn get_tags_handler( ); } - match registry_client::get_tags(&payload.image).await { + match registry_client::get_tags( + &payload.image, + payload.include.as_deref(), + payload.exclude.as_deref(), + ) + .await + { Ok(json) => (StatusCode::OK, [("content-type", "application/json")], json), Err(e) => ( StatusCode::BAD_REQUEST, diff --git a/src/registry_client.rs b/src/registry_client.rs index 59d8d9a..04af204 100644 --- a/src/registry_client.rs +++ b/src/registry_client.rs @@ -1,23 +1,104 @@ -use oci_client::client::ClientConfig; use oci_client::{Client, Reference, secrets::RegistryAuth}; +use regex::Regex; +use semver::Version; use serde::Serialize; +use serde_json; +use std::error::Error; #[derive(Serialize)] -struct SerializableTags<'a> { +struct LatestTags<'a> { name: &'a str, - tags: &'a [String], + latest_tags: &'a [String], } -pub async fn get_tags(image: &str) -> Result> { - let client = Client::new(ClientConfig::default()); - let img_ref: Reference = image.parse()?; - let auth = RegistryAuth::Anonymous; - let tags = client - .list_tags(&img_ref, &auth, Some(100), Some("17")) - .await?; - let serialized_tags = SerializableTags { - name: &tags.name, - tags: &tags.tags, - }; - Ok(serde_json::to_string(&serialized_tags)?) +/// Extracts the first MAJOR.MINOR.PATCH or MAJOR.MINOR from a tag. +/// If only MAJOR.MINOR is found, appends .0 for patch. +fn extract_version(tag: &str) -> Option { + // Try to find MAJOR.MINOR.PATCH + let re_patch = Regex::new(r"\d+\.\d+\.\d+").unwrap(); + if let Some(mat) = re_patch.find(tag) { + return Version::parse(mat.as_str()).ok(); + } + // Try to find MAJOR.MINOR and treat as MAJOR.MINOR.0 + let re_minor = Regex::new(r"\d+\.\d+").unwrap(); + if let Some(mat) = re_minor.find(tag) { + let version_str = format!("{}.0", mat.as_str()); + return Version::parse(&version_str).ok(); + } + None +} + +fn tag_matches_filters(tag: &str, include: Option<&[String]>, exclude: Option<&[String]>) -> bool { + let includes = include.map_or(true, |incs| incs.iter().all(|inc| tag.contains(inc))); + let excludes = exclude.map_or(true, |excs| excs.iter().all(|exc| !tag.contains(exc))); + includes && excludes +} + +async fn fetch_all_tags( + client: &Client, + image_ref: &Reference, + auth: &RegistryAuth, +) -> Result, Box> { + let mut all_tags = Vec::new(); + let mut last_tag: Option = None; + + loop { + let response = client + .list_tags(image_ref, auth, Some(100), last_tag.as_deref()) + .await?; + + let tags = response.tags; + if tags.is_empty() { + break; + } + + let is_last_page = tags.len() < 100; + last_tag = tags.last().cloned(); + all_tags.extend(tags); + + if is_last_page { + break; + } + } + + Ok(all_tags) +} + +async fn get_sorted_tags( + image: &str, + include: Option<&[String]>, + exclude: Option<&[String]>, +) -> Result, Box> { + let client = Client::new(Default::default()); + let image_ref: Reference = image.parse()?; + let auth = RegistryAuth::Anonymous; + + let all_tags = fetch_all_tags(&client, &image_ref, &auth).await?; + + let mut parsed_tags: Vec<(String, Version)> = all_tags + .into_iter() + .filter(|tag| tag_matches_filters(tag, include, exclude)) + .filter_map(|tag| extract_version(&tag).map(|ver| (tag, ver))) + .collect(); + + parsed_tags.sort_by(|a, b| b.1.cmp(&a.1)); + + Ok(parsed_tags + .into_iter() + .take(10) + .map(|(tag, _)| tag) + .collect()) +} + +pub async fn get_tags( + image: &str, + include: Option<&[String]>, + exclude: Option<&[String]>, +) -> Result> { + let tags = get_sorted_tags(image, include, exclude).await?; + let output = LatestTags { + name: image, + latest_tags: &tags, + }; + Ok(serde_json::to_string_pretty(&output)?) }