diff --git a/src/check.rs b/src/check.rs index d01104a..e49c5b2 100644 --- a/src/check.rs +++ b/src/check.rs @@ -8,8 +8,6 @@ use crate::{ utils::new_reqwest_client, }; -use crate::registry::get_latest_digest; - /// Trait for a type that implements a function `unique` that removes any duplicates. /// In this case, it will be used for a Vec. pub trait Unique { @@ -77,7 +75,7 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec { // Loop through images and get the latest digest for each for image in images { let token = tokens.get(&image.registry.as_ref().unwrap()).unwrap(); - let future = get_latest_digest(image, token.as_ref(), config, &client); + let future = image.check(token.as_ref(), config, &client); handles.push(future); } // Await all the futures diff --git a/src/formatting.rs b/src/formatting.rs index 7888d98..fa334e3 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -17,15 +17,17 @@ pub fn print_updates(updates: &[Image], icons: &bool) { let description = has_update.to_string(); let icon = if *icons { match has_update { - Status::UpdateAvailable => "\u{f0aa} ", Status::UpToDate => "\u{f058} ", Status::Unknown(_) => "\u{f059} ", + _ => "\u{f0aa} ", } } else { "" }; let color = match has_update { - Status::UpdateAvailable => "\u{001b}[38;5;12m", + Status::UpdateAvailable | Status::UpdatePatch => "\u{001b}[38;5;12m", + Status::UpdateMinor => "\u{001b}[38;5;3m", + Status::UpdateMajor => "\u{001b}[38;5;1m", Status::UpToDate => "\u{001b}[38;5;2m", Status::Unknown(_) => "\u{001b}[38;5;8m", }; diff --git a/src/image.rs b/src/image.rs index 6758619..3d8294a 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,11 +1,16 @@ -use std::fmt::Display; +use std::{cmp::Ordering, fmt::Display}; use bollard::models::{ImageInspect, ImageSummary}; use json::{object, JsonValue}; use once_cell::sync::Lazy; use regex::Regex; +use reqwest_middleware::ClientWithMiddleware; -use crate::error; +use crate::{ + config::Config, + error, + registry::{get_latest_digest, get_latest_tag}, +}; /// Image struct that contains all information that may be needed by a function. /// It's designed to be passed around between functions @@ -17,6 +22,8 @@ pub struct Image { pub tag: Option, pub local_digests: Option>, pub remote_digest: Option, + pub semver_tag: Option, + pub latest_remote_tag: Option, pub error: Option, pub time_ms: i64, } @@ -41,6 +48,7 @@ impl Image { image.registry = Some(registry); image.repository = Some(repository); image.tag = Some(tag); + image.semver_tag = image.get_version(); return Some(image); } @@ -70,6 +78,7 @@ impl Image { image.registry = Some(registry); image.repository = Some(repository); image.tag = Some(tag); + image.semver_tag = image.get_version(); return Some(image); } @@ -112,6 +121,11 @@ impl Image { pub fn has_update(&self) -> Status { if self.error.is_some() { Status::Unknown(self.error.clone().unwrap()) + } else if self.latest_remote_tag.is_some() { + self.latest_remote_tag + .as_ref() + .unwrap() + .to_status(self.semver_tag.as_ref().unwrap()) } else if self .local_digests .as_ref() @@ -151,6 +165,19 @@ impl Image { pub fn get_version(&self) -> Option { get_version(self.tag.as_ref().unwrap()) } + + /// Checks if the image has an update + pub async fn check( + &self, + token: Option<&String>, + config: &Config, + client: &ClientWithMiddleware, + ) -> Self { + match &self.semver_tag { + Some(version) => get_latest_tag(self, version, token, config, client).await, + None => get_latest_digest(self, token, config, client).await, + } + } } /// Tries to parse the tag into semver parts. Should have been included in impl Image, but that would make the tests more complicated @@ -179,14 +206,8 @@ pub fn get_version(tag: &str) -> Option { Some(major) => major.as_str().parse().unwrap(), None => return None, }; - let minor: i32 = match c.name("minor") { - Some(minor) => minor.as_str().parse().unwrap(), - None => 0, - }; - let patch: i32 = match c.name("patch") { - Some(patch) => patch.as_str().parse().unwrap(), - None => 0, - }; + let minor: Option = c.name("minor").map(|minor| minor.as_str().parse().unwrap()); + let patch: Option = c.name("patch").map(|patch| patch.as_str().parse().unwrap()); Some(SemVer { major, minor, @@ -212,9 +233,13 @@ static SEMVER: Lazy = Lazy::new(|| { }); /// Enum for image status +#[derive(Ord, Eq, PartialEq, PartialOrd)] pub enum Status { - UpToDate, + UpdateMajor, + UpdateMinor, + UpdatePatch, UpdateAvailable, + UpToDate, Unknown(String), } @@ -223,6 +248,9 @@ impl Display for Status { f.write_str(match &self { Self::UpToDate => "Up to date", Self::UpdateAvailable => "Update available", + Self::UpdateMajor => "Major update", + Self::UpdateMinor => "Minor update", + Self::UpdatePatch => "Patch update", Self::Unknown(_) => "Unknown", }) } @@ -232,18 +260,74 @@ impl Status { // Converts the Status into an Option (useful for JSON serialization) pub fn to_option_bool(&self) -> Option { match &self { - Self::UpdateAvailable => Some(true), Self::UpToDate => Some(false), Self::Unknown(_) => None, + _ => Some(true), } } } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub struct SemVer { - major: i32, - minor: i32, - patch: i32, + pub major: i32, + pub minor: Option, + pub patch: Option, +} + +impl SemVer { + fn to_status(&self, base: &Self) -> Status { + if self.major == base.major { + match (self.minor, base.minor) { + (Some(a_minor), Some(b_minor)) => { + if a_minor == b_minor { + match (self.patch, base.patch) { + (Some(a_patch), Some(b_patch)) => { + if a_patch == b_patch { + unreachable!() + } else { + Status::UpdatePatch + } + } + _ => unreachable!(), + } + } else { + Status::UpdateMinor + } + } + _ => unreachable!(), + } + } else { + Status::UpdateMajor + } + } +} + +impl Ord for SemVer { + fn cmp(&self, other: &Self) -> Ordering { + let major_ordering = self.major.cmp(&other.major); + match major_ordering { + Ordering::Equal => match (self.minor, other.minor) { + (Some(self_minor), Some(other_minor)) => { + let minor_ordering = self_minor.cmp(&other_minor); + match minor_ordering { + Ordering::Equal => match (self.patch, other.patch) { + (Some(self_patch), Some(other_patch)) => self_patch.cmp(&other_patch), + _ => Ordering::Equal, + }, + _ => minor_ordering, + } + } + _ => Ordering::Equal, + }, + _ => major_ordering, + } + } +} + +impl PartialOrd for SemVer { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } #[cfg(test)] @@ -253,21 +337,21 @@ mod tests { #[test] #[rustfmt::skip] fn semver() { - assert_eq!(get_version("5.3.2" ).unwrap(), SemVer { major: 5, minor: 3, patch: 2 }); - assert_eq!(get_version("14" ).unwrap(), SemVer { major: 14, minor: 0, patch: 0 }); - assert_eq!(get_version("v0.107.53" ).unwrap(), SemVer { major: 0, minor: 107, patch: 53 }); - assert_eq!(get_version("12-alpine" ).unwrap(), SemVer { major: 12, minor: 0, patch: 0 }); - assert_eq!(get_version("0.9.5-nginx" ).unwrap(), SemVer { major: 0, minor: 9, patch: 5 }); - assert_eq!(get_version("v27.0" ).unwrap(), SemVer { major: 27, minor: 0, patch: 0 }); - assert_eq!(get_version("16.1" ).unwrap(), SemVer { major: 16, minor: 1, patch: 0 }); - assert_eq!(get_version("version-1.5.6" ).unwrap(), SemVer { major: 1, minor: 5, patch: 6 }); - assert_eq!(get_version("15.4-alpine" ).unwrap(), SemVer { major: 15, minor: 4, patch: 0 }); - assert_eq!(get_version("pg14-v0.2.0" ).unwrap(), SemVer { major: 0, minor: 2, patch: 0 }); - assert_eq!(get_version("18-jammy-full.s6-v0.88.0").unwrap(), SemVer { major: 0, minor: 88, patch: 0 }); - assert_eq!(get_version("fpm-2.1.0-prod" ).unwrap(), SemVer { major: 2, minor: 1, patch: 0 }); - assert_eq!(get_version("7.3.3.50" ).unwrap(), SemVer { major: 7, minor: 3, patch: 3 }); - assert_eq!(get_version("1.21.11-0" ).unwrap(), SemVer { major: 1, minor: 21, patch: 11 }); - assert_eq!(get_version("4.1.2.1-full" ).unwrap(), SemVer { major: 4, minor: 1, patch: 2 }); - assert_eq!(get_version("v4.0.3-ls215" ).unwrap(), SemVer { major: 4, minor: 0, patch: 3 }); + assert_eq!(get_version("5.3.2" ), Some(SemVer { major: 5, minor: Some(3), patch: Some(2) })); + assert_eq!(get_version("14" ), Some(SemVer { major: 14, minor: Some(0), patch: Some(0) })); + assert_eq!(get_version("v0.107.53" ), Some(SemVer { major: 0, minor: Some(107), patch: Some(53) })); + assert_eq!(get_version("12-alpine" ), Some(SemVer { major: 12, minor: Some(0), patch: Some(0) })); + assert_eq!(get_version("0.9.5-nginx" ), Some(SemVer { major: 0, minor: Some(9), patch: Some(5) })); + assert_eq!(get_version("v27.0" ), Some(SemVer { major: 27, minor: Some(0), patch: Some(0) })); + assert_eq!(get_version("16.1" ), Some(SemVer { major: 16, minor: Some(1), patch: Some(0) })); + assert_eq!(get_version("version-1.5.6" ), Some(SemVer { major: 1, minor: Some(5), patch: Some(6) })); + assert_eq!(get_version("15.4-alpine" ), Some(SemVer { major: 15, minor: Some(4), patch: Some(0) })); + assert_eq!(get_version("pg14-v0.2.0" ), Some(SemVer { major: 0, minor: Some(2), patch: Some(0) })); + assert_eq!(get_version("18-jammy-full.s6-v0.88.0"), Some(SemVer { major: 0, minor: Some(88), patch: Some(0) })); + assert_eq!(get_version("fpm-2.1.0-prod" ), Some(SemVer { major: 2, minor: Some(1), patch: Some(0) })); + assert_eq!(get_version("7.3.3.50" ), Some(SemVer { major: 7, minor: Some(3), patch: Some(3) })); + assert_eq!(get_version("1.21.11-0" ), Some(SemVer { major: 1, minor: Some(21), patch: Some(11) })); + assert_eq!(get_version("4.1.2.1-full" ), Some(SemVer { major: 4, minor: Some(1), patch: Some(2) })); + assert_eq!(get_version("v4.0.3-ls215" ), Some(SemVer { major: 4, minor: Some(0), patch: Some(3) })); } } diff --git a/src/registry.rs b/src/registry.rs index 68d52dd..95b999e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -3,7 +3,13 @@ use json::JsonValue; use http_auth::parse_challenges; use reqwest_middleware::ClientWithMiddleware; -use crate::{config::Config, error, image::Image, utils::timestamp, warn}; +use crate::{ + config::Config, + error, + image::{get_version, Image, SemVer}, + utils::timestamp, + warn, +}; pub async fn check_auth( registry: &str, @@ -85,23 +91,23 @@ pub async fn get_latest_digest( let status = response.status(); if status == 401 { if token.is_some() { - warn!("Failed to authenticate to registry {} with token provided!\n{}", &image.registry.as_ref().unwrap(), token.unwrap()); - return Image { remote_digest: None, error: Some(format!("Authentication token \"{}\" was not accepted", token.unwrap())), time_ms: timestamp() - start, ..image.clone() } + warn!("Failed to authenticate to registry {} with token provided!\n{}", image.registry.as_ref().unwrap(), token.unwrap()); + return Image { error: Some(format!("Authentication token \"{}\" was not accepted", token.unwrap())), time_ms: timestamp() - start, ..image.clone() } } else { - warn!("Registry requires authentication"); - return Image { remote_digest: None, error: Some("Registry requires authentication".to_string()), time_ms: timestamp() - start, ..image.clone() } + warn!("Registry {} requires authentication", image.registry.as_ref().unwrap()); + return Image { error: Some("Registry requires authentication".to_string()), time_ms: timestamp() - start, ..image.clone() } } } else if status == 404 { warn!("Image {:?} not found", &image); - return Image { remote_digest: None, error: Some("Image not found".to_string()), time_ms: timestamp() - start, ..image.clone() } + return Image { error: Some("Image not found".to_string()), time_ms: timestamp() - start, ..image.clone() } } else { response } }, Err(e) => { if e.is_connect() { - warn!("Connection to registry failed."); - return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), time_ms: timestamp() - start, ..image.clone() } + warn!("Connection to registry {} failed.", image.registry.as_ref().unwrap()); + return Image { error: Some("Connection to registry failed".to_string()), time_ms: timestamp() - start, ..image.clone() } } else { error!("Unexpected error: {}", e.to_string()) } @@ -134,9 +140,7 @@ pub async fn get_token( image.repository.as_ref().unwrap() ); } - let mut base_request = client - .get(&final_url) - .header("Accept", "application/vnd.oci.image.index.v1+json"); // Seems to be unnecessary. Will probably remove in the future + let mut base_request = client.get(&final_url); base_request = match credentials { Some(creds) => base_request.header("Authorization", &format!("Basic {}", creds)), None => base_request, @@ -165,6 +169,129 @@ pub async fn get_token( parsed_token_response["token"].to_string() } +pub async fn get_latest_tag( + image: &Image, + base: &SemVer, + token: Option<&String>, + config: &Config, + client: &ClientWithMiddleware, +) -> Image { + let start = timestamp(); + + // Start creating request + let protocol = if config + .insecure_registries + .contains(&image.registry.clone().unwrap()) + { + "http" + } else { + "https" + }; + let mut request = client.get(format!( + "{}://{}/v2/{}/tags/list", + protocol, + &image.registry.as_ref().unwrap(), + &image.repository.as_ref().unwrap(), + )); + if let Some(t) = token { + request = request.header("Authorization", &format!("Bearer {}", t)); + } + + // Send request + let raw_response = match request.header("Accept", "application/json").send().await { + Ok(response) => { + let status = response.status(); + if status == 401 { + if token.is_some() { + warn!( + "Failed to authenticate to registry {} with token provided!\n{}", + image.registry.as_ref().unwrap(), + token.unwrap() + ); + return Image { + error: Some(format!( + "Authentication token \"{}\" was not accepted", + token.unwrap() + )), + time_ms: timestamp() - start, + ..image.clone() + }; + } else { + warn!( + "Registry {} requires authentication", + image.registry.as_ref().unwrap() + ); + return Image { + error: Some("Registry requires authentication".to_string()), + time_ms: timestamp() - start, + ..image.clone() + }; + } + } else if status == 404 { + warn!("Image {:?} not found", &image); + return Image { + error: Some("Image not found".to_string()), + time_ms: timestamp() - start, + ..image.clone() + }; + } else { + match response.text().await { + Ok(res) => res, + Err(e) => { + error!("Failed to parse registry response into string!\n{}", e) + } + } + } + } + Err(e) => { + if e.is_connect() { + warn!( + "Connection to registry {} failed.", + image.registry.as_ref().unwrap() + ); + return Image { + error: Some("Connection to registry failed".to_string()), + time_ms: timestamp() - start, + ..image.clone() + }; + } else { + error!("Unexpected error: {}", e.to_string()) + } + } + }; + let parsed_response: JsonValue = match json::parse(&raw_response) { + Ok(parsed) => parsed, + Err(e) => { + error!("Failed to parse server response\n{}", e) + } + }; + let tag = parsed_response["tags"] + .members() + .filter_map(|tag| get_version(&tag.to_string())) + .filter(|tag| match (base.minor, tag.minor) { + (Some(_), Some(_)) | (None, None) => { + matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None)) + } + _ => false, + }) + .max(); + match tag { + Some(t) => { + if t == *base { + // Tags are equal so we'll compare digests + get_latest_digest(image, token, config, client).await + } else { + Image { + latest_remote_tag: Some(t), + time_ms: timestamp() - start, + ..image.clone() + } + } + } + None => unreachable!(), + } +} + fn parse_www_authenticate(www_auth: &str) -> String { let challenges = parse_challenges(www_auth).unwrap(); if !challenges.is_empty() { diff --git a/src/utils.rs b/src/utils.rs index 9f296f4..b4ebb55 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -8,32 +8,32 @@ use crate::image::{Image, Status}; /// Sorts the update vector alphabetically and where Some(true) > Some(false) > None pub fn sort_image_vec(updates: &[Image]) -> Vec { let mut sorted_updates = updates.to_vec(); - sorted_updates.sort_unstable_by(|a, b| match (a.has_update(), b.has_update()) { - (Status::UpdateAvailable, Status::UpdateAvailable) => a.reference.cmp(&b.reference), - (Status::UpdateAvailable, Status::UpToDate | Status::Unknown(_)) => { - std::cmp::Ordering::Less - } - (Status::UpToDate, Status::UpdateAvailable) => std::cmp::Ordering::Greater, - (Status::UpToDate, Status::UpToDate) => a.reference.cmp(&b.reference), - (Status::UpToDate, Status::Unknown(_)) => std::cmp::Ordering::Less, - (Status::Unknown(_), Status::UpdateAvailable | Status::UpToDate) => { - std::cmp::Ordering::Greater - } - (Status::Unknown(_), Status::Unknown(_)) => a.reference.cmp(&b.reference), - }); + sorted_updates.sort_unstable_by_key(|img| img.has_update()); sorted_updates.to_vec() } /// Helper function to get metrics used in JSON output pub fn get_metrics(updates: &[Image]) -> JsonValue { let mut up_to_date = 0; - let mut update_available = 0; + let mut major_updates = 0; + let mut minor_updates = 0; + let mut patch_updates = 0; + let mut other_updates = 0; let mut unknown = 0; updates.iter().for_each(|image| { let has_update = image.has_update(); match has_update { + Status::UpdateMajor => { + major_updates += 1; + } + Status::UpdateMinor => { + minor_updates += 1; + } + Status::UpdatePatch => { + patch_updates += 1; + } Status::UpdateAvailable => { - update_available += 1; + other_updates += 1; } Status::UpToDate => { up_to_date += 1; @@ -46,7 +46,10 @@ pub fn get_metrics(updates: &[Image]) -> JsonValue { object! { monitored_images: updates.len(), up_to_date: up_to_date, - update_available: update_available, + major_updates: major_updates, + minor_updates: minor_updates, + patch_updates: patch_updates, + other_updates: other_updates, unknown: unknown } }