diff --git a/Cargo.lock b/Cargo.lock index a95f40a..6ab19fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,7 +346,9 @@ dependencies = [ "clap", "futures", "http-auth", + "http-link", "indicatif", + "itertools", "json", "liquid", "once_cell", @@ -619,6 +621,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-link" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500f1fc191bab8d956904c49818a167fd19534dbd529d93bd030bdc3bf9117a0" +dependencies = [ + "percent-encoding", + "url", +] + [[package]] name = "httparse" version = "1.9.4" @@ -1900,9 +1912,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "untrusted" @@ -2047,9 +2059,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd24728e5af82c6c4ec1b66ac4844bdf8156257fccda846ec58b42cd0cdbe6a" +checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index 13d389d..6225e8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,14 +6,14 @@ edition = "2021" [dependencies] clap = { version = "4.5.7", features = ["derive"] } indicatif = { version = "0.17.8", optional = true } -tokio = {version = "1.38.0", features = ["macros"]} +tokio = { version = "1.38.0", features = ["macros"] } xitca-web = { version = "0.5.0", optional = true, features = ["logger"] } liquid = { version = "0.26.6", optional = true } bollard = "0.16.1" once_cell = "1.19.0" http-auth = { version = "0.1.9", default-features = false, features = [] } termsize = { version = "0.1.8", optional = true } -regex = "1.10.5" +regex = { version = "1.10.5", default-features = false, features = ["perf"] } chrono = { version = "0.4.38", default-features = false, features = ["std", "alloc", "clock"], optional = true } json = "0.12.4" reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tls"] } @@ -21,6 +21,8 @@ futures = "0.3.30" reqwest-retry = "0.6.1" reqwest-middleware = "0.3.3" rustc-hash = "2.0.0" +http-link = "1.0.1" +itertools = "0.13.0" [features] default = ["server", "cli"] diff --git a/README.md b/README.md index 0fed217..ff81d77 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,6 @@ Take a look at https://sergi0g.github.io/cup/docs! Cup is a work in progress. It might not have as many features as other alternatives. If one of these features is really important for you, please consider using another tool. -- Cup (currently) does not support semver. - Cup cannot directly trigger your integrations. If you want that to happen automatically, please use What's up Docker instead. Cup was created to be simple. The data is there, and it's up to you to retrieve it (e.g. by running `cup check -r` with a cronjob or periodically requesting the `/json` url from the server). ## Roadmap diff --git a/src/check.rs b/src/check.rs index e49c5b2..8206d7c 100644 --- a/src/check.rs +++ b/src/check.rs @@ -1,56 +1,36 @@ use futures::future::join_all; -use rustc_hash::{FxHashMap, FxHashSet}; +use rustc_hash::FxHashMap; +use itertools::Itertools; use crate::{ config::Config, - image::Image, + http::Client, registry::{check_auth, get_token}, - utils::new_reqwest_client, + structs::image::Image, }; -/// 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 { - fn unique(&mut self) -> Vec; -} - -impl Unique for Vec -where - T: Clone + Eq + std::hash::Hash, -{ - /// Remove duplicates from Vec - fn unique(self: &mut Vec) -> Self { - let mut seen: FxHashSet = FxHashSet::default(); - self.retain(|item| seen.insert(item.clone())); - self.to_vec() - } -} - /// Returns a list of updates for all images passed in. pub async fn get_updates(images: &[Image], config: &Config) -> Vec { // Get a list of unique registries our images belong to. We are unwrapping the registry because it's guaranteed to be there. let registries: Vec<&String> = images .iter() - .map(|image| image.registry.as_ref().unwrap()) - .collect::>() - .unique(); + .map(|image| &image.registry) + .unique() + .collect::>(); // Create request client. All network requests share the same client for better performance. // This client is also configured to retry a failed request up to 3 times with exponential backoff in between. - let client = new_reqwest_client(); + let client = Client::new(); // Create a map of images indexed by registry. This solution seems quite inefficient, since each iteration causes a key to be looked up. I can't find anything better at the moment. let mut image_map: FxHashMap<&String, Vec<&Image>> = FxHashMap::default(); for image in images { - image_map - .entry(image.registry.as_ref().unwrap()) - .or_default() - .push(image); + image_map.entry(&image.registry).or_default().push(image); } // Retrieve an authentication token (if required) for each registry. - let mut tokens: FxHashMap<&String, Option> = FxHashMap::default(); + let mut tokens: FxHashMap<&str, Option> = FxHashMap::default(); for registry in registries { let credentials = config.authentication.get(registry); match check_auth(registry, config, &client).await { @@ -74,7 +54,7 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec { let mut handles = Vec::new(); // 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 token = tokens.get(image.registry.as_str()).unwrap(); let future = image.check(token.as_ref(), config, &client); handles.push(future); } diff --git a/src/docker.rs b/src/docker.rs index bd97199..e2a60c5 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -2,7 +2,7 @@ use bollard::{models::ImageInspect, ClientVersion, Docker}; use futures::future::join_all; -use crate::{config::Config, error, image::Image}; +use crate::{config::Config, error, structs::image::Image}; fn create_docker_client(socket: Option) -> Docker { let client: Result = match socket { @@ -29,7 +29,7 @@ pub async fn get_images_from_docker_daemon( references: &Option>, ) -> Vec { let client: Docker = create_docker_client(config.socket.clone()); - // If https://github.com/moby/moby/issues/48612 is fixed, this code should be faster. For now a workaround will be used. + // If https://github.com/moby/moby/issues/48612 is fixed, this code should be faster (if it works, it may also be entirely stupid). For now a workaround will be used. // let mut filters = HashMap::with_capacity(1); // match references { // Some(refs) => { @@ -72,7 +72,7 @@ pub async fn get_images_from_docker_daemon( .collect(); let mut image_handles = Vec::with_capacity(inspects.len()); for inspect in inspects { - image_handles.push(Image::from_inspect(inspect.clone())); + image_handles.push(Image::from_inspect_data(inspect.clone())); } join_all(image_handles) .await @@ -89,7 +89,7 @@ pub async fn get_images_from_docker_daemon( }; let mut handles = Vec::new(); for image in images { - handles.push(Image::from_summary(image)) + handles.push(Image::from_inspect_data(image)) } join_all(handles) .await diff --git a/src/formatting.rs b/src/formatting/mod.rs similarity index 56% rename from src/formatting.rs rename to src/formatting/mod.rs index fa334e3..2a7812f 100644 --- a/src/formatting.rs +++ b/src/formatting/mod.rs @@ -1,10 +1,8 @@ -use std::time::Duration; - -use indicatif::{ProgressBar, ProgressStyle}; +pub mod spinner; use crate::{ - image::{Image, Status}, - utils::{sort_image_vec, to_simple_json}, + structs::{image::Image, status::Status}, + utils::{json::to_simple_json, sort_update_vec::sort_image_vec}, }; pub fn print_updates(updates: &[Image], icons: &bool) { @@ -43,31 +41,3 @@ pub fn print_updates(updates: &[Image], icons: &bool) { pub fn print_raw_updates(updates: &[Image]) { println!("{}", json::stringify(to_simple_json(updates))); } - -pub struct Spinner { - spinner: ProgressBar, -} - -impl Spinner { - #[allow(clippy::new_without_default)] - pub fn new() -> Spinner { - let spinner = ProgressBar::new_spinner(); - let style: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - let progress_style = ProgressStyle::default_spinner(); - - spinner.set_style(ProgressStyle::tick_strings(progress_style, style)); - - spinner.set_message("Checking..."); - spinner.enable_steady_tick(Duration::from_millis(50)); - - Spinner { spinner } - } - pub fn succeed(&self) { - const CHECKMARK: &str = "\u{001b}[32;1m\u{2713}\u{001b}[0m"; - - let success_message = format!("{} Done!", CHECKMARK); - self.spinner - .set_style(ProgressStyle::with_template("{msg}").unwrap()); - self.spinner.finish_with_message(success_message); - } -} diff --git a/src/formatting/spinner.rs b/src/formatting/spinner.rs new file mode 100644 index 0000000..cc5891a --- /dev/null +++ b/src/formatting/spinner.rs @@ -0,0 +1,31 @@ +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; + +pub struct Spinner { + spinner: ProgressBar, +} + +impl Spinner { + #[allow(clippy::new_without_default)] + pub fn new() -> Spinner { + let spinner = ProgressBar::new_spinner(); + let style: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let progress_style = ProgressStyle::default_spinner(); + + spinner.set_style(ProgressStyle::tick_strings(progress_style, style)); + + spinner.set_message("Checking..."); + spinner.enable_steady_tick(Duration::from_millis(50)); + + Spinner { spinner } + } + pub fn succeed(&self) { + const CHECKMARK: &str = "\u{001b}[32;1m\u{2713}\u{001b}[0m"; + + let success_message = format!("{} Done!", CHECKMARK); + self.spinner + .set_style(ProgressStyle::with_template("{msg}").unwrap()); + self.spinner.finish_with_message(success_message); + } +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..eecb848 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,131 @@ +use std::fmt::Display; + +use reqwest::Response; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; +use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; + +use crate::{error, warn}; + +pub enum RequestMethod { + GET, + HEAD, +} + +impl Display for RequestMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + RequestMethod::GET => "GET", + RequestMethod::HEAD => "HEAD", + }) + } +} + +/// A struct for handling HTTP requests. Takes care of the repetitive work of checking for errors, etc and exposes a simple interface +pub struct Client { + inner: ClientWithMiddleware, +} + +impl Client { + pub fn new() -> Self { + Self { + inner: ClientBuilder::new(reqwest::Client::new()) + .with(RetryTransientMiddleware::new_with_policy( + ExponentialBackoff::builder().build_with_max_retries(3), + )) + .build(), + } + } + + async fn request( + &self, + url: &str, + method: RequestMethod, + headers: Vec<(&str, Option<&str>)>, + ignore_401: bool, + ) -> Result { + let mut request = match method { + RequestMethod::GET => self.inner.get(url), + RequestMethod::HEAD => self.inner.head(url), + }; + for (name, value) in headers { + if let Some(v) = value { + request = request.header(name, v) + } + } + match request.send().await { + Ok(response) => { + let status = response.status(); + if status == 404 { + let message = format!("{} {}: Not found!", method, url); + warn!("{}", message); + Err(message) + } else if status == 401 { + if ignore_401 { + Ok(response) + } else { + let message = format!("{} {}: Unauthorized! Please configure authentication for this registry or if you have already done so, please make sure it is correct.", method, url); + warn!("{}", message); + Err(message) + } + } else if status.as_u16() <= 400 { + Ok(response) + } else { + match method { + RequestMethod::GET => error!( + "{} {}: Unexpected error: {}", + method, + url, + response.text().await.unwrap() + ), + RequestMethod::HEAD => error!( + "{} {}: Unexpected error: Recieved status code {}", + method, url, status + ), + } + } + } + Err(error) => { + if error.is_connect() { + let message = format!("{} {}: Connection failed!", method, url); + warn!("{}", message); + Err(message) + } else if error.is_timeout() { + let message = format!("{} {}: Connection timed out!", method, url); + warn!("{}", message); + Err(message) + } else { + error!( + "{} {}: Unexpected error: {}", + method, + url, + error.to_string() + ) + } + } + } + } + + pub async fn get( + &self, + url: &str, + headers: Vec<(&str, Option<&str>)>, + ignore_401: bool, + ) -> Result { + self.request(url, RequestMethod::GET, headers, ignore_401) + .await + } + + pub async fn head( + &self, + url: &str, + headers: Vec<(&str, Option<&str>)>, + ) -> Result { + self.request(url, RequestMethod::HEAD, headers, false).await + } +} + +impl Default for Client { + fn default() -> Self { + Self::new() + } +} diff --git a/src/image.rs b/src/image.rs deleted file mode 100644 index 3d8294a..0000000 --- a/src/image.rs +++ /dev/null @@ -1,357 +0,0 @@ -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::{ - 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 -#[derive(Clone, Debug, PartialEq, Default)] -pub struct Image { - pub reference: String, - pub registry: Option, - pub repository: Option, - 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, -} - -impl Image { - /// Creates an populates the fields of an Image object based on the ImageSummary from the Docker daemon - pub async fn from_summary(image: ImageSummary) -> Option { - if !image.repo_tags.is_empty() && !image.repo_digests.is_empty() { - let mut image = Image { - reference: image.repo_tags[0].clone(), - local_digests: Some( - image - .repo_digests - .clone() - .iter() - .map(|digest| digest.split('@').collect::>()[1].to_string()) - .collect(), - ), - ..Default::default() - }; - let (registry, repository, tag) = image.split(); - image.registry = Some(registry); - image.repository = Some(repository); - image.tag = Some(tag); - image.semver_tag = image.get_version(); - - return Some(image); - } - None - } - - pub async fn from_inspect(image: ImageInspect) -> Option { - if image.repo_tags.is_some() - && !image.repo_tags.as_ref().unwrap().is_empty() - && image.repo_digests.is_some() - && !image.repo_digests.as_ref().unwrap().is_empty() - { - let mut image = Image { - reference: image.repo_tags.as_ref().unwrap()[0].clone(), - local_digests: Some( - image - .repo_digests - .unwrap() - .clone() - .iter() - .map(|digest| digest.split('@').collect::>()[1].to_string()) - .collect(), - ), - ..Default::default() - }; - let (registry, repository, tag) = image.split(); - image.registry = Some(registry); - image.repository = Some(repository); - image.tag = Some(tag); - image.semver_tag = image.get_version(); - - return Some(image); - } - None - } - - /// Takes an image and splits it into registry, repository and tag, based on the reference. - /// For example, `ghcr.io/sergi0g/cup:latest` becomes `['ghcr.io', 'sergi0g/cup', 'latest']`. - pub fn split(&self) -> (String, String, String) { - match RE.captures(&self.reference) { - Some(c) => { - let registry = match c.name("registry") { - Some(registry) => registry.as_str().to_owned(), - None => String::from("registry-1.docker.io"), - }; - return ( - registry.clone(), - match c.name("repository") { - Some(repository) => { - let repo = repository.as_str().to_owned(); - if !repo.contains('/') && registry == "registry-1.docker.io" { - format!("library/{}", repo) - } else { - repo - } - } - None => error!("Failed to parse image {}", &self.reference), - }, - match c.name("tag") { - Some(tag) => tag.as_str().to_owned(), - None => String::from("latest"), - }, - ); - } - None => error!("Failed to parse image {}", &self.reference), - } - } - - /// Compares remote digest of the image with its local digests to determine if it has an update or not - 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() - .unwrap() - .contains(self.remote_digest.as_ref().unwrap()) - { - Status::UpToDate - } else { - Status::UpdateAvailable - } - } - - /// Converts image data into a `JsonValue` - pub fn to_json(&self) -> JsonValue { - let has_update = self.has_update(); - object! { - reference: self.reference.clone(), - parts: object! { - registry: self.registry.clone(), - repository: self.repository.clone(), - tag: self.tag.clone() - }, - local_digests: self.local_digests.clone(), - remote_digest: self.remote_digest.clone(), - result: object! { // API here will have to change for semver - has_update: has_update.to_option_bool(), - error: match has_update { - Status::Unknown(e) => Some(e), - _ => None - } - }, - time: self.time_ms - } - } - - /// Tries to parse the tag into semver parts - 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 -pub fn get_version(tag: &str) -> Option { - let captures = SEMVER.captures_iter(tag); - // And now... terrible best match selection for everyone! - let mut max_matches = 0; - let mut best_match = None; - for capture in captures { - let mut count = 0; - for idx in 1..capture.len() { - if capture.get(idx).is_some() { - count += 1 - } else { - break; - } - } - if count > max_matches { - max_matches = count; - best_match = Some(capture); - } - } - match best_match { - Some(c) => { - let major: i32 = match c.name("major") { - Some(major) => major.as_str().parse().unwrap(), - None => return None, - }; - 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, - patch, - }) - } - None => None, - } -} - -/// Regex to match Docker image references against, so registry, repository and tag can be extracted. -static RE: Lazy = Lazy::new(|| { - Regex::new( - r#"^(?P(?:(?P(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P[\w][\w.-]{0,127}))?$"#, // From https://regex101.com/r/nmSDPA/1 - ) - .unwrap() -}); - -/// Heavily modified version of the official semver regex based on common tagging schemes for container images. Sometimes it matches more than once, but we'll try to select the best match. Yes, there _will_ be errors. -static SEMVER: Lazy = Lazy::new(|| { - Regex::new(r#"(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?(?:\.(?P0|[1-9]\d*)+)?"#) - .unwrap() -}); - -/// Enum for image status -#[derive(Ord, Eq, PartialEq, PartialOrd)] -pub enum Status { - UpdateMajor, - UpdateMinor, - UpdatePatch, - UpdateAvailable, - UpToDate, - Unknown(String), -} - -impl Display for Status { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 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", - }) - } -} - -impl Status { - // Converts the Status into an Option (useful for JSON serialization) - pub fn to_option_bool(&self) -> Option { - match &self { - Self::UpToDate => Some(false), - Self::Unknown(_) => None, - _ => Some(true), - } - } -} - -#[derive(Debug, PartialEq, Eq, Clone)] -pub struct SemVer { - 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)] -mod tests { - use super::*; - - #[test] - #[rustfmt::skip] - fn semver() { - 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/main.rs b/src/main.rs index 995ba1c..2954812 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,22 +2,24 @@ use check::get_updates; use clap::{Parser, Subcommand}; use config::Config; use docker::get_images_from_docker_daemon; +use formatting::spinner::Spinner; #[cfg(feature = "cli")] -use formatting::{print_raw_updates, print_updates, Spinner}; +use formatting::{print_raw_updates, print_updates}; #[cfg(feature = "server")] use server::serve; use std::path::PathBuf; -use utils::timestamp; +use utils::misc::timestamp; pub mod check; pub mod config; pub mod docker; #[cfg(feature = "cli")] pub mod formatting; -pub mod image; +pub mod http; pub mod registry; #[cfg(feature = "server")] pub mod server; +pub mod structs; pub mod utils; #[derive(Parser)] diff --git a/src/registry.rs b/src/registry.rs index 95b999e..76b14dc 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,60 +1,42 @@ -use json::JsonValue; - -use http_auth::parse_challenges; -use reqwest_middleware::ClientWithMiddleware; +use itertools::Itertools; use crate::{ config::Config, error, - image::{get_version, Image, SemVer}, - utils::timestamp, - warn, + http::Client, + structs::{ + image::{DigestInfo, Image, VersionInfo}, + version::Version, + }, + utils::{ + link::parse_link, + misc::timestamp, + request::{ + get_protocol, get_response_body, parse_json, parse_www_authenticate, to_bearer_string, + }, + }, }; -pub async fn check_auth( - registry: &str, - config: &Config, - client: &ClientWithMiddleware, -) -> Option { - let protocol = if config.insecure_registries.contains(®istry.to_string()) { - "http" - } else { - "https" - }; - let response = client - .get(format!("{}://{}/v2/", protocol, registry)) - .send() - .await; +pub async fn check_auth(registry: &str, config: &Config, client: &Client) -> Option { + let protocol = get_protocol(®istry.to_string(), &config.insecure_registries); + let url = format!("{}://{}/v2/", protocol, registry); + let response = client.get(&url, Vec::new(), true).await; match response { - Ok(r) => { - let status = r.status().as_u16(); + Ok(response) => { + let status = response.status(); if status == 401 { - match r.headers().get("www-authenticate") { - Some(challenge) => Some(parse_www_authenticate(challenge.to_str().unwrap())), - None => error!( - "Unauthorized to access registry {} and no way to authenticate was provided", - registry - ), - } - } else if status == 200 { - None + match response.headers().get("www-authenticate") { + Some(challenge) => Some(parse_www_authenticate(challenge.to_str().unwrap())), + None => error!( + "Unauthorized to access registry {} and no way to authenticate was provided", + registry + ), + } } else { - warn!( - "Received unexpected status code {}\nResponse: {}", - status, - r.text().await.unwrap() - ); None } } - Err(e) => { - if e.is_connect() { - warn!("Connection to registry {} failed.", ®istry); - None - } else { - error!("Unexpected error: {}", e.to_string()) - } - } + Err(_) => None, } } @@ -62,67 +44,44 @@ pub async fn get_latest_digest( image: &Image, token: Option<&String>, config: &Config, - client: &ClientWithMiddleware, + client: &Client, ) -> Image { let start = timestamp(); - let protocol = if config - .insecure_registries - .contains(&image.registry.clone().unwrap()) - { - "http" - } else { - "https" - }; - let mut request = client.head(format!( + let protocol = get_protocol(&image.registry, &config.insecure_registries); + let url = format!( "{}://{}/v2/{}/manifests/{}", - protocol, - &image.registry.as_ref().unwrap(), - &image.repository.as_ref().unwrap(), - &image.tag.as_ref().unwrap() - )); - if let Some(t) = token { - request = request.header("Authorization", &format!("Bearer {}", t)); - } - let raw_response = match request - .header("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+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() } + protocol, &image.registry, &image.repository, &image.tag + ); + let authorization = to_bearer_string(&token); + let headers = vec![("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")), ("Authorization", authorization.as_deref())]; + + let response = client.head(&url, headers).await; + match response { + Ok(res) => match res.headers().get("docker-content-digest") { + Some(digest) => { + let local_digests = match &image.digest_info { + Some(data) => data.local_digests.clone(), + None => return image.clone(), + }; + Image { + digest_info: Some(DigestInfo { + remote_digest: Some(digest.to_str().unwrap().to_string()), + local_digests, + }), + time_ms: image.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 { - response } + None => error!( + "Server returned invalid response! No docker-content-digest!\n{:#?}", + res + ), }, - 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()) - } - }, - }; - match raw_response.headers().get("docker-content-digest") { - Some(digest) => Image { - remote_digest: Some(digest.to_str().unwrap().to_string()), - time_ms: timestamp() - start, + Err(error) => Image { + error: Some(error), + time_ms: image.time_ms + (timestamp() - start), ..image.clone() }, - None => error!( - "Server returned invalid response! No docker-content-digest!\n{:#?}", - raw_response - ), } } @@ -130,182 +89,128 @@ pub async fn get_token( images: &Vec<&Image>, auth_url: &str, credentials: &Option<&String>, - client: &ClientWithMiddleware, + client: &Client, ) -> String { - let mut final_url = auth_url.to_owned(); + let mut url = auth_url.to_owned(); for image in images { - final_url = format!( - "{}&scope=repository:{}:pull", - final_url, - image.repository.as_ref().unwrap() - ); + url = format!("{}&scope=repository:{}:pull", url, image.repository); } - let mut base_request = client.get(&final_url); - base_request = match credentials { - Some(creds) => base_request.header("Authorization", &format!("Basic {}", creds)), - None => base_request, + let authorization = credentials.as_ref().map(|creds| format!("Basic {}", creds)); + let headers = vec![("Authorization", authorization.as_deref())]; + + let response = client.get(&url, headers, false).await; + let response_json = match response { + Ok(response) => parse_json(&get_response_body(response).await), + Err(_) => error!("GET {}: Request failed!", url), }; - let raw_response = match base_request.send().await { - Ok(response) => 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() { - error!("Connection to registry failed."); - } else { - error!("Token request failed!\n{}", e.to_string()) - } - } - }; - let parsed_token_response: JsonValue = match json::parse(&raw_response) { - Ok(parsed) => parsed, - Err(e) => { - error!("Failed to parse server response\n{}", e) - } - }; - parsed_token_response["token"].to_string() + response_json["token"].to_string() } pub async fn get_latest_tag( image: &Image, - base: &SemVer, + base: &Version, token: Option<&String>, config: &Config, - client: &ClientWithMiddleware, + client: &Client, ) -> 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!( + let protocol = get_protocol(&image.registry, &config.insecure_registries); + let url = 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)); - } + protocol, &image.registry, &image.repository, + ); + let authorization = to_bearer_string(&token); + let headers = vec![ + ("Accept", Some("application/json")), + ("Authorization", authorization.as_deref()), + ]; - // 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() - ); + let mut tags: Vec = Vec::new(); + let mut next_url = Some(url); + + while next_url.is_some() { + let mut new_tags = Vec::new(); + (new_tags, next_url) = + match get_extra_tags(&next_url.unwrap(), headers.clone(), base, client).await { + Ok(t) => t, + Err(message) => { return Image { - error: Some(format!( - "Authentication token \"{}\" was not accepted", - token.unwrap() - )), - time_ms: timestamp() - start, + error: Some(message), + time_ms: image.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, - }) + }; + tags.append(&mut new_tags); + } + let tag = tags + .iter() .max(); + let current_tag = match &image.version_info { + Some(data) => data.current_tag.clone(), + _ => unreachable!(), + }; match tag { Some(t) => { - if t == *base { + if t == base { // Tags are equal so we'll compare digests - get_latest_digest(image, token, config, client).await + get_latest_digest( + &Image { + version_info: Some(VersionInfo { + current_tag, + latest_remote_tag: Some(t.clone()), + }), + time_ms: image.time_ms + (timestamp() - start), + ..image.clone() + }, + token, + config, + client, + ) + .await } else { Image { - latest_remote_tag: Some(t), - time_ms: timestamp() - start, + version_info: Some(VersionInfo { + current_tag, + latest_remote_tag: Some(t.clone()), + }), + time_ms: image.time_ms + (timestamp() - start), ..image.clone() } } } - None => unreachable!(), + None => unreachable!("{:?}", tags), } } -fn parse_www_authenticate(www_auth: &str) -> String { - let challenges = parse_challenges(www_auth).unwrap(); - if !challenges.is_empty() { - let challenge = &challenges[0]; - if challenge.scheme == "Bearer" { - format!( - "{}?service={}", - challenge.params[0].1.as_escaped(), - challenge.params[1].1.as_escaped() - ) - } else { - error!("Unsupported scheme {}", &challenge.scheme) +pub async fn get_extra_tags( + url: &str, + headers: Vec<(&str, Option<&str>)>, + base: &Version, + client: &Client, +) -> Result<(Vec, Option), String> { + let response = client.get(&url, headers, false).await; + + match response { + Ok(res) => { + let next_url = match res.headers().get("Link") { + Some(link) => Some(parse_link(link.to_str().unwrap(), &url)), + None => None, + }; + let response_json = parse_json(&get_response_body(res).await); + let result = response_json["tags"] + .members() + .filter_map(|tag| Version::from_tag(&tag.to_string())) + .filter(|tag| match (base.minor, tag.minor) { + (Some(_), Some(_)) | (None, None) => { + matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None)) + } + _ => false, + }) + .dedup() + .collect(); + Ok((result, next_url)) } - } else { - error!("No challenge provided by the server"); + Err(message) => Err(message), } } diff --git a/src/server.rs b/src/server.rs index 68dc26a..a82066b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -17,9 +17,13 @@ use crate::{ check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, - image::Image, info, - utils::{sort_image_vec, timestamp, to_full_json, to_simple_json}, + structs::image::Image, + utils::{ + json::{to_full_json, to_simple_json}, + misc::timestamp, + sort_update_vec::sort_image_vec, + }, }; const HTML: &str = include_str!("static/index.html"); diff --git a/src/structs/image.rs b/src/structs/image.rs new file mode 100644 index 0000000..0fa4023 --- /dev/null +++ b/src/structs/image.rs @@ -0,0 +1,166 @@ +use json::{object, JsonValue}; + +use crate::{ + config::Config, + http::Client, + registry::{get_latest_digest, get_latest_tag}, + structs::{status::Status, version::Version}, + utils::reference::split, +}; + +use super::inspectdata::InspectData; + +#[derive(Clone, PartialEq)] +#[cfg_attr(test, derive(Debug))] +pub struct DigestInfo { + pub local_digests: Vec, + pub remote_digest: Option, +} + +#[derive(Clone, PartialEq)] +#[cfg_attr(test, derive(Debug))] +pub struct VersionInfo { + pub current_tag: Version, + pub latest_remote_tag: Option, +} + +/// Image struct that contains all information that may be needed by a function working with an image. +/// It's designed to be passed around between functions +#[derive(Clone, PartialEq, Default)] +#[cfg_attr(test, derive(Debug))] +pub struct Image { + pub reference: String, + pub registry: String, + pub repository: String, + pub tag: String, + pub digest_info: Option, + pub version_info: Option, + pub error: Option, + pub time_ms: i64, +} + +impl Image { + /// Creates an populates the fields of an Image object based on the ImageSummary from the Docker daemon + pub async fn from_inspect_data(image: T) -> Option { + let tags = image.tags().unwrap(); + let digests = image.digests().unwrap(); + if !tags.is_empty() && !digests.is_empty() { + let reference = tags[0].clone(); + let (registry, repository, tag) = split(&reference); + let version_tag = Version::from_tag(&tag); + let local_digests = digests + .iter() + .map(|digest| digest.split('@').collect::>()[1].to_string()) + .collect(); + Some(Self { + reference, + registry, + repository, + tag, + digest_info: Some(DigestInfo { + local_digests, + remote_digest: None, + }), + version_info: version_tag.map(|stag| VersionInfo { + current_tag: stag, + latest_remote_tag: None, + }), + ..Default::default() + }) + } else { + None + } + } + + /// Compares remote digest of the image with its local digests to determine if it has an update or not + pub fn has_update(&self) -> Status { + if self.error.is_some() { + Status::Unknown(self.error.clone().unwrap()) + } else { + match &self.version_info { + Some(data) => data + .latest_remote_tag + .as_ref() + .unwrap() + .to_status(&data.current_tag), + None => match &self.digest_info { + Some(data) => { + if data + .local_digests + .contains(data.remote_digest.as_ref().unwrap()) + { + Status::UpToDate + } else { + Status::UpdateAvailable + } + } + None => unreachable!(), // I hope? + }, + } + } + } + + /// Converts image data into a `JsonValue` + pub fn to_json(&self) -> JsonValue { + let has_update = self.has_update(); + let update_type = match has_update { + Status::UpdateMajor | Status::UpdateMinor | Status::UpdatePatch => "version", + _ => "digest", + }; + object! { + reference: self.reference.clone(), + parts: object! { + registry: self.registry.clone(), + repository: self.repository.clone(), + tag: self.tag.clone() + }, + result: object! { + has_update: has_update.to_option_bool(), + info: match has_update { + Status::Unknown(_) => None, + _ => Some(match update_type { + "version" => { + let (version_tag, latest_remote_tag) = match &self.version_info { + Some(data) => (data.current_tag.clone(), data.latest_remote_tag.clone()), + _ => unreachable!() + }; + object! { + "type": update_type, + version_update_type: match has_update { + Status::UpdateMajor => "major", + Status::UpdateMinor => "minor", + Status::UpdatePatch => "patch", + _ => unreachable!() + }, + new_version: self.tag.replace(&version_tag.to_string(), &latest_remote_tag.as_ref().unwrap().to_string()) + } + }, + "digest" => { + let (local_digests, remote_digest) = match &self.digest_info { + Some(data) => (data.local_digests.clone(), data.remote_digest.clone()), + _ => unreachable!() + }; + object! { + "type": update_type, + local_digests: local_digests, + remote_digest: remote_digest, + } + }, + _ => unreachable!() + }) + }}, + time: self.time_ms + } + } + + /// Checks if the image has an update + pub async fn check(&self, token: Option<&String>, config: &Config, client: &Client) -> Self { + match &self.version_info { + Some(data) => get_latest_tag(self, &data.current_tag, token, config, client).await, + None => match self.digest_info { + Some(_) => get_latest_digest(self, token, config, client).await, + None => unreachable!(), + }, + } + } +} diff --git a/src/structs/inspectdata.rs b/src/structs/inspectdata.rs new file mode 100644 index 0000000..a73262f --- /dev/null +++ b/src/structs/inspectdata.rs @@ -0,0 +1,26 @@ +use bollard::secret::{ImageInspect, ImageSummary}; + +pub trait InspectData { + fn tags(&self) -> Option>; + fn digests(&self) -> Option>; +} + +impl InspectData for ImageInspect { + fn tags(&self) -> Option> { + self.repo_tags.clone() + } + + fn digests(&self) -> Option> { + self.repo_digests.clone() + } +} + +impl InspectData for ImageSummary { + fn tags(&self) -> Option> { + Some(self.repo_tags.clone()) + } + + fn digests(&self) -> Option> { + Some(self.repo_digests.clone()) + } +} diff --git a/src/structs/mod.rs b/src/structs/mod.rs new file mode 100644 index 0000000..42235fa --- /dev/null +++ b/src/structs/mod.rs @@ -0,0 +1,4 @@ +pub mod image; +pub mod inspectdata; +pub mod status; +pub mod version; diff --git a/src/structs/status.rs b/src/structs/status.rs new file mode 100644 index 0000000..6801378 --- /dev/null +++ b/src/structs/status.rs @@ -0,0 +1,36 @@ +use std::fmt::Display; + +/// Enum for image status +#[derive(Ord, Eq, PartialEq, PartialOrd)] +pub enum Status { + UpdateMajor, + UpdateMinor, + UpdatePatch, + UpdateAvailable, + UpToDate, + Unknown(String), +} + +impl Display for Status { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + 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", + }) + } +} + +impl Status { + // Converts the Status into an Option (useful for JSON serialization) + pub fn to_option_bool(&self) -> Option { + match &self { + Self::UpToDate => Some(false), + Self::Unknown(_) => None, + _ => Some(true), + } + } +} diff --git a/src/structs/version.rs b/src/structs/version.rs new file mode 100644 index 0000000..0eab411 --- /dev/null +++ b/src/structs/version.rs @@ -0,0 +1,164 @@ +use std::{cmp::Ordering, fmt::Display}; + +use once_cell::sync::Lazy; +use regex::Regex; + +use super::status::Status; + +/// Heavily modified version of the official semver regex based on common tagging schemes for container images. Sometimes it matches more than once, but we'll try to select the best match. Yes, there _will_ be errors. +static SEMVER_REGEX: Lazy = Lazy::new(|| { + Regex::new(r#"(?P0|[1-9]\d*)(?:\.(?P0|[1-9]\d*))?(?:\.(?P0|[1-9]\d*)+)?"#) + .unwrap() +}); + +/// Semver-like version struct +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Version { + pub major: u32, + pub minor: Option, + pub patch: Option, +} + +impl Version { + /// Tries to parse the tag into semver-like parts. Should have been included in impl Image, but that would make the tests more complicated + pub fn from_tag(tag: &str) -> Option { + let captures = SEMVER_REGEX.captures_iter(tag); + // And now... terrible best match selection for everyone! + let mut max_matches = 0; + let mut best_match = None; + for capture in captures { + let mut count = 0; + for idx in 1..capture.len() { + if capture.get(idx).is_some() { + count += 1 + } else { + break; + } + } + if count > max_matches { + max_matches = count; + best_match = Some(capture); + } + } + match best_match { + Some(c) => { + let major: u32 = match c.name("major") { + Some(major) => major.as_str().parse().unwrap(), + None => return None, + }; + 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(Version { + major, + minor, + patch, + }) + } + None => None, + } + } + + pub 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 { + Status::UpToDate + } else { + Status::UpdatePatch + } + } + (None, None) => Status::UpToDate, + _ => unreachable!(), + } + } else { + Status::UpdateMinor + } + } + (None, None) => Status::UpToDate, + _ => unreachable!( + "Version error: {} and {} should either both be Some or None", + self, base + ), + } + } else { + Status::UpdateMajor + } + } +} + +impl Ord for Version { + 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 Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "{}{}{}", + self.major, + match self.minor { + Some(minor) => format!(".{}", minor), + None => String::new(), + }, + match self.patch { + Some(patch) => format!(".{}", patch), + None => String::new(), + } + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[rustfmt::skip] + fn version() { + assert_eq!(Version::from_tag("5.3.2" ), Some(Version { major: 5, minor: Some(3), patch: Some(2) })); + assert_eq!(Version::from_tag("14" ), Some(Version { major: 14, minor: Some(0), patch: Some(0) })); + assert_eq!(Version::from_tag("v0.107.53" ), Some(Version { major: 0, minor: Some(107), patch: Some(53) })); + assert_eq!(Version::from_tag("12-alpine" ), Some(Version { major: 12, minor: Some(0), patch: Some(0) })); + assert_eq!(Version::from_tag("0.9.5-nginx" ), Some(Version { major: 0, minor: Some(9), patch: Some(5) })); + assert_eq!(Version::from_tag("v27.0" ), Some(Version { major: 27, minor: Some(0), patch: Some(0) })); + assert_eq!(Version::from_tag("16.1" ), Some(Version { major: 16, minor: Some(1), patch: Some(0) })); + assert_eq!(Version::from_tag("version-1.5.6" ), Some(Version { major: 1, minor: Some(5), patch: Some(6) })); + assert_eq!(Version::from_tag("15.4-alpine" ), Some(Version { major: 15, minor: Some(4), patch: Some(0) })); + assert_eq!(Version::from_tag("pg14-v0.2.0" ), Some(Version { major: 0, minor: Some(2), patch: Some(0) })); + assert_eq!(Version::from_tag("18-jammy-full.s6-v0.88.0"), Some(Version { major: 0, minor: Some(88), patch: Some(0) })); + assert_eq!(Version::from_tag("fpm-2.1.0-prod" ), Some(Version { major: 2, minor: Some(1), patch: Some(0) })); + assert_eq!(Version::from_tag("7.3.3.50" ), Some(Version { major: 7, minor: Some(3), patch: Some(3) })); + assert_eq!(Version::from_tag("1.21.11-0" ), Some(Version { major: 1, minor: Some(21), patch: Some(11) })); + assert_eq!(Version::from_tag("4.1.2.1-full" ), Some(Version { major: 4, minor: Some(1), patch: Some(2) })); + assert_eq!(Version::from_tag("v4.0.3-ls215" ), Some(Version { major: 4, minor: Some(0), patch: Some(3) })); + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index b4ebb55..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,202 +0,0 @@ -use chrono::Local; -use json::{object, JsonValue}; -use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; -use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; - -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_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 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 => { - other_updates += 1; - } - Status::UpToDate => { - up_to_date += 1; - } - Status::Unknown(_) => { - unknown += 1; - } - }; - }); - object! { - monitored_images: updates.len(), - up_to_date: up_to_date, - major_updates: major_updates, - minor_updates: minor_updates, - patch_updates: patch_updates, - other_updates: other_updates, - unknown: unknown - } -} - -/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. The output doesn't contain much detail -pub fn to_simple_json(updates: &[Image]) -> JsonValue { - let mut json_data: JsonValue = object! { - metrics: get_metrics(updates), - images: object! {} - }; - updates.iter().for_each(|image| { - let _ = json_data["images"].insert(&image.reference, image.has_update().to_option_bool()); - }); - json_data -} - -/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. All image data is included, useful for debugging. -pub fn to_full_json(updates: &[Image]) -> JsonValue { - object! { - metrics: get_metrics(updates), - images: updates.iter().map(|image| image.to_json()).collect::>(), - } -} - -// Logging - -/// This macro is an alternative to panic. It prints the message you give it and exits the process with code 1, without printing a stack trace. Useful for when the program has to exit due to a user error or something unexpected which is unrelated to the program (e.g. a failed web request) -#[macro_export] -macro_rules! error { - ($($arg:tt)*) => ({ - eprintln!("\x1b[38:5:204mERROR \x1b[0m {}", format!($($arg)*)); - std::process::exit(1); - }) -} - -// A small macro to print in yellow as a warning -#[macro_export] -macro_rules! warn { - ($($arg:tt)*) => ({ - eprintln!("\x1b[38:5:192mWARN \x1b[0m {}", format!($($arg)*)); - }) -} - -#[macro_export] -macro_rules! info { - ($($arg:tt)*) => ({ - println!("\x1b[38:5:86mINFO \x1b[0m {}", format!($($arg)*)); - }) -} - -#[macro_export] -macro_rules! debug { - ($($arg:tt)*) => ({ - println!("\x1b[38:5:63mDEBUG \x1b[0m {}", format!($($arg)*)); - }) -} - -/// Creates a new reqwest client with automatic retries -pub fn new_reqwest_client() -> ClientWithMiddleware { - ClientBuilder::new(reqwest::Client::new()) - .with(RetryTransientMiddleware::new_with_policy( - ExponentialBackoff::builder().build_with_max_retries(3), - )) - .build() -} - -pub fn timestamp() -> i64 { - Local::now().timestamp_millis() -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Test the `sort_update_vec` function - #[test] - fn test_ordering() { - // Create test objects - let update_available_1 = Image { - reference: "busybox".to_string(), - local_digests: Some(vec![ - "some_digest".to_string(), - "some_other_digest".to_string(), - ]), - remote_digest: Some("latest_digest".to_string()), - ..Default::default() - }; - let update_available_2 = Image { - reference: "library/alpine".to_string(), - local_digests: Some(vec![ - "some_digest".to_string(), - "some_other_digest".to_string(), - ]), // We don't need to mock real data, as this is a generic function - remote_digest: Some("latest_digest".to_string()), - ..Default::default() - }; - let up_to_date_1 = Image { - reference: "docker:dind".to_string(), - local_digests: Some(vec![ - "some_digest".to_string(), - "some_other_digest".to_string(), - "latest_digest".to_string(), - ]), - remote_digest: Some("latest_digest".to_string()), - ..Default::default() - }; - let up_to_date_2 = Image { - reference: "ghcr.io/sergi0g/cup".to_string(), - local_digests: Some(vec![ - "some_digest".to_string(), - "some_other_digest".to_string(), - "latest_digest".to_string(), - ]), - remote_digest: Some("latest_digest".to_string()), - ..Default::default() - }; - let unknown_1 = Image { - reference: "fake_registry.com/fake/image".to_string(), - error: Some("whoops".to_string()), - ..Default::default() - }; - let unknown_2 = Image { - reference: "private_registry.io/private/image".to_string(), - error: Some("whoops".to_string()), - ..Default::default() - }; - let input_vec = vec![ - unknown_2.clone(), - up_to_date_1.clone(), - unknown_1.clone(), - update_available_2.clone(), - update_available_1.clone(), - up_to_date_2.clone(), - ]; - let expected_vec = vec![ - update_available_1, - update_available_2, - up_to_date_1, - up_to_date_2, - unknown_1, - unknown_2, - ]; - - // Sort the vec - let sorted_vec = sort_image_vec(&input_vec); - - // Check results - assert_eq!(sorted_vec, expected_vec); - } -} diff --git a/src/utils/json.rs b/src/utils/json.rs new file mode 100644 index 0000000..71d0d68 --- /dev/null +++ b/src/utils/json.rs @@ -0,0 +1,68 @@ +// Functions that return JSON data, used for generating output and API responses + +use json::{object, JsonValue}; + +use crate::structs::{image::Image, status::Status}; + +/// Helper function to get metrics used in JSON output +pub fn get_metrics(updates: &[Image]) -> JsonValue { + let mut up_to_date = 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 => { + other_updates += 1; + } + Status::UpToDate => { + up_to_date += 1; + } + Status::Unknown(_) => { + unknown += 1; + } + }; + }); + object! { + monitored_images: updates.len(), + up_to_date: up_to_date, + updates_available: major_updates + minor_updates + patch_updates + other_updates, + major_updates: major_updates, + minor_updates: minor_updates, + patch_updates: patch_updates, + other_updates: other_updates, + unknown: unknown + } +} + +/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. The output doesn't contain much detail +pub fn to_simple_json(updates: &[Image]) -> JsonValue { + let mut json_data: JsonValue = object! { + metrics: get_metrics(updates), + images: object! {} + }; + updates.iter().for_each(|image| { + let _ = json_data["images"].insert(&image.reference, image.has_update().to_option_bool()); + }); + json_data +} + +/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. All image data is included, useful for debugging. +pub fn to_full_json(updates: &[Image]) -> JsonValue { + object! { + metrics: get_metrics(updates), + images: updates.iter().map(|image| image.to_json()).collect::>(), + } +} diff --git a/src/utils/link.rs b/src/utils/link.rs new file mode 100644 index 0000000..c62b11b --- /dev/null +++ b/src/utils/link.rs @@ -0,0 +1,13 @@ +use std::str::FromStr; + +use http_link::parse_link_header; +use reqwest::Url; + +use crate::error; + +pub fn parse_link(link: &str, base: &str) -> String { + match parse_link_header(link, &Url::from_str(base).unwrap()) { + Ok(l) => l[0].target.to_string(), + Err(e) => error!("Failed to parse link! {}", e) + } +} \ No newline at end of file diff --git a/src/utils/logging.rs b/src/utils/logging.rs new file mode 100644 index 0000000..8b03550 --- /dev/null +++ b/src/utils/logging.rs @@ -0,0 +1,32 @@ +// Logging utilites + +/// This macro is an alternative to panic. It prints the message you give it and exits the process with code 1, without printing a stack trace. Useful for when the program has to exit due to a user error or something unexpected which is unrelated to the program (e.g. a failed web request) +#[macro_export] +macro_rules! error { + ($($arg:tt)*) => ({ + eprintln!("\x1b[38:5:204mERROR \x1b[0m {}", format!($($arg)*)); + std::process::exit(1); + }) +} + +// A small macro to print in yellow as a warning +#[macro_export] +macro_rules! warn { + ($($arg:tt)*) => ({ + eprintln!("\x1b[38:5:192mWARN \x1b[0m {}", format!($($arg)*)); + }) +} + +#[macro_export] +macro_rules! info { + ($($arg:tt)*) => ({ + println!("\x1b[38:5:86mINFO \x1b[0m {}", format!($($arg)*)); + }) +} + +#[macro_export] +macro_rules! debug { + ($($arg:tt)*) => ({ + println!("\x1b[38:5:63mDEBUG \x1b[0m {}", format!($($arg)*)); + }) +} diff --git a/src/utils/misc.rs b/src/utils/misc.rs new file mode 100644 index 0000000..2b31ad9 --- /dev/null +++ b/src/utils/misc.rs @@ -0,0 +1,8 @@ +// Miscellaneous utility functions that are too small to go in a separate file + +use chrono::Local; + +/// Gets the current timestamp. Mainly exists so I don't have to type this one line of code ;-) +pub fn timestamp() -> i64 { + Local::now().timestamp_millis() +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..e8290d4 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,7 @@ +pub mod json; +pub mod logging; +pub mod misc; +pub mod reference; +pub mod request; +pub mod sort_update_vec; +pub mod link; \ No newline at end of file diff --git a/src/utils/reference.rs b/src/utils/reference.rs new file mode 100644 index 0000000..2bc4764 --- /dev/null +++ b/src/utils/reference.rs @@ -0,0 +1,46 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +use crate::error; + +const DEFAULT_REGISTRY: &str = "registry-1.docker.io"; + +/// Takes an image and splits it into registry, repository and tag, based on the reference. +/// For example, `ghcr.io/sergi0g/cup:latest` becomes `['ghcr.io', 'sergi0g/cup', 'latest']`. +pub fn split(reference: &str) -> (String, String, String) { + match REFERENCE_REGEX.captures(reference) { + Some(c) => { + let registry = match c.name("registry") { + Some(registry) => registry.as_str().to_owned(), + None => String::from(DEFAULT_REGISTRY), + }; + return ( + registry.clone(), + match c.name("repository") { + Some(repository) => { + let repo = repository.as_str().to_owned(); + if !repo.contains('/') && registry == DEFAULT_REGISTRY { + format!("library/{}", repo) + } else { + repo + } + } + None => error!("Failed to parse image {}", reference), + }, + match c.name("tag") { + Some(tag) => tag.as_str().to_owned(), + None => String::from("latest"), + }, + ); + } + None => error!("Failed to parse image {}", reference), + } +} + +/// Regex to match Docker image references against, so registry, repository and tag can be extracted. +static REFERENCE_REGEX: Lazy = Lazy::new(|| { + Regex::new( + r#"^(?P(?:(?P(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P[\w][\w.-]{0,127}))?$"#, // From https://regex101.com/r/nmSDPA/1 + ) + .unwrap() +}); diff --git a/src/utils/request.rs b/src/utils/request.rs new file mode 100644 index 0000000..efbbd7a --- /dev/null +++ b/src/utils/request.rs @@ -0,0 +1,55 @@ +use http_auth::parse_challenges; +use json::JsonValue; +use reqwest::Response; + +use crate::error; + +/// Parses the www-authenticate header the registry sends into a challenge URL +pub fn parse_www_authenticate(www_auth: &str) -> String { + let challenges = parse_challenges(www_auth).unwrap(); + if !challenges.is_empty() { + let challenge = &challenges[0]; + if challenge.scheme == "Bearer" { + format!( + "{}?service={}", + challenge.params[0].1.as_escaped(), + challenge.params[1].1.as_escaped() + ) + } else { + error!("Unsupported scheme {}", &challenge.scheme) + } + } else { + error!("No challenge provided by the server"); + } +} + +pub fn get_protocol(registry: &String, insecure_registries: &[String]) -> String { + if insecure_registries.contains(registry) { + "http" + } else { + "https" + } + .to_string() +} + +pub fn to_bearer_string(token: &Option<&String>) -> Option { + token.as_ref().map(|t| format!("Bearer {}", t)) +} + +pub async fn get_response_body(response: Response) -> String { + match response.text().await { + Ok(res) => res, + Err(e) => { + error!("Failed to parse registry response into string!\n{}", e) + } + } +} + +pub fn parse_json(body: &str) -> JsonValue { + match json::parse(body) { + Ok(parsed) => parsed, + Err(e) => { + error!("Failed to parse server response\n{}", e) + } + } +} diff --git a/src/utils/sort_update_vec.rs b/src/utils/sort_update_vec.rs new file mode 100644 index 0000000..c34fc1c --- /dev/null +++ b/src/utils/sort_update_vec.rs @@ -0,0 +1,94 @@ +use crate::structs::image::Image; + +/// 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_key(|img| img.has_update()); + sorted_updates.to_vec() +} + +#[cfg(test)] +mod tests { + use crate::structs::image::DigestInfo; + + use super::*; + + /// Test the `sort_update_vec` function + /// TODO: test semver as well + #[test] + fn test_ordering() { + // Create test objects + let update_available_1 = Image { + reference: "busybox".to_string(), + digest_info: Some(DigestInfo { + local_digests: vec!["some_digest".to_string(), "some_other_digest".to_string()], + remote_digest: Some("latest_digest".to_string()), + }), + ..Default::default() + }; + let update_available_2 = Image { + reference: "library/alpine".to_string(), + digest_info: Some(DigestInfo { + local_digests: vec!["some_digest".to_string(), "some_other_digest".to_string()], + remote_digest: Some("latest_digest".to_string()), + }), + ..Default::default() + }; + let up_to_date_1 = Image { + reference: "docker:dind".to_string(), + digest_info: Some(DigestInfo { + local_digests: vec![ + "some_digest".to_string(), + "some_other_digest".to_string(), + "latest_digest".to_string(), + ], + remote_digest: Some("latest_digest".to_string()), + }), + ..Default::default() + }; + let up_to_date_2 = Image { + reference: "ghcr.io/sergi0g/cup".to_string(), + digest_info: Some(DigestInfo { + local_digests: vec![ + "some_digest".to_string(), + "some_other_digest".to_string(), + "latest_digest".to_string(), + ], + remote_digest: Some("latest_digest".to_string()), + }), + ..Default::default() + }; + let unknown_1 = Image { + reference: "fake_registry.com/fake/image".to_string(), + error: Some("whoops".to_string()), + ..Default::default() + }; + let unknown_2 = Image { + reference: "private_registry.io/private/image".to_string(), + error: Some("whoops".to_string()), + ..Default::default() + }; + let input_vec = vec![ + unknown_2.clone(), + up_to_date_1.clone(), + unknown_1.clone(), + update_available_2.clone(), + update_available_1.clone(), + up_to_date_2.clone(), + ]; + let expected_vec = vec![ + update_available_1, + update_available_2, + up_to_date_1, + up_to_date_2, + unknown_1, + unknown_2, + ]; + + // Sort the vec + let sorted_vec = sort_image_vec(&input_vec); + + // Check results + assert_eq!(sorted_vec, expected_vec); + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 3bbc983..2283ffa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,8 +29,8 @@ function App() { className={`bg-white shadow-sm dark:bg-${theme}-900 my-8 rounded-md`} >
- {Object.entries(data.metrics).map(([name, value]) => ( - + {Object.entries(data.metrics).map(([name]) => ( + ))}
diff --git a/web/src/components/Image.tsx b/web/src/components/Image.tsx index e49f75d..9d9d647 100644 --- a/web/src/components/Image.tsx +++ b/web/src/components/Image.tsx @@ -67,30 +67,7 @@ export default function Image({ data }: { data: Image }) {
  • {data.reference} - {data.result.has_update == false && ( - - - - )} - {data.result.has_update == true && ( - - - - )} - {data.result.has_update == null && ( - - - - )} +
  • @@ -133,24 +110,7 @@ export default function Image({ data }: { data: Image }) {
    - {data.result.has_update == false && ( - <> - - Up to date - - )} - {data.result.has_update == true && ( - <> - - Update available - - )} - {data.result.has_update == null && ( - <> - - Unknown - - )} +
    @@ -171,7 +131,7 @@ export default function Image({ data }: { data: Image }) { className={`bg-${theme}-100 dark:bg-${theme}-950 group relative mb-4 flex items-center rounded-md px-3 py-2 font-mono text-gray-500`} >

    - docker pull {data.reference} + docker pull {data.result.info?.type == "version" ? data.reference.replace(data.parts.tag, data.result.info.new_version) : data.reference}

    {navigator.clipboard && (copySuccess ? ( @@ -180,7 +140,7 @@ export default function Image({ data }: { data: Image }) {
    )}
    - {data.local_digests.length > 1 ? "Local digests" : "Local digest"} -
    -

    - {data.local_digests.join("\n")} -

    -
    + {data.result.info?.type == "digest" && ( + <> + {data.result.info.local_digests.length > 1 + ? "Local digests" + : "Local digest"} +
    +

    + {data.result.info.local_digests.join("\n")} +

    +
    + {data.result.info.remote_digest && ( +
    + Remote digest +
    +

    + {data.result.info.remote_digest} +

    +
    +
    + )} + + )}
    - {data.remote_digest && ( -
    - Remote digest -
    -

    {data.remote_digest}

    -
    -
    - )} @@ -217,3 +185,119 @@ export default function Image({ data }: { data: Image }) { ); } + +function Icon({ data }: { data: Image }) { + switch (data.result.has_update) { + case null: + return ( + + + + ); + case false: + return ( + + + + ); + case true: + if (data.result.info?.type === "version") { + switch (data.result.info.version_update_type) { + case "major": + return ( + + + + ); + case "minor": + return ( + + + + ); + case "patch": + return ( + + + + ); + } + } else if (data.result.info?.type === "digest") { + return ( + + + + ); + } + } +} + +function DialogIcon({ data }: { data: Image }) { + switch (data.result.has_update) { + case null: + return ( + <> + + Unknown + + ); + case false: + return ( + <> + + Up to date + + ); + case true: + if (data.result.info?.type === "version") { + switch (data.result.info.version_update_type) { + case "major": + return ( + <> + + Major update + + ); + case "minor": + return ( + <> + + Minor update + + ); + case "patch": + return ( + <> + + Patch update + + ); + } + } else if (data.result.info?.type === "digest") { + return ( + <> + + Update available + + ); + } + } +} diff --git a/web/src/components/Statistic.tsx b/web/src/components/Statistic.tsx index 906bac9..27172fd 100644 --- a/web/src/components/Statistic.tsx +++ b/web/src/components/Statistic.tsx @@ -5,16 +5,25 @@ import { IconHelpCircleFilled, } from "@tabler/icons-react"; import { theme } from "../theme"; +import { Data } from "../types"; + +const metricsToShow = [ + "monitored_images", + "up_to_date", + "updates_available", + "unknown", +]; export default function Statistic({ name, - value, + metrics, }: { - name: string; - value: number; + name: keyof Data["metrics"]; + metrics: Data["metrics"]; }) { - name = name.replaceAll("_", " "); - name = name.slice(0, 1).toUpperCase() + name.slice(1); // Capitalize name + if (!metricsToShow.includes(name)) return null; + let displayName = name.replaceAll("_", " "); + displayName = displayName.slice(0, 1).toUpperCase() + displayName.slice(1); // Capitalize name return (
    - {name} + {displayName}
    - {value} + {metrics[name]}
    - {name == "Monitored images" && ( + {name === "monitored_images" && ( )} - {name == "Up to date" && ( + {name === "up_to_date" && ( )} - {name == "Update available" && ( - - )} - {name == "Unknown" && ( + {name === "updates_available" && getUpdatesAvailableIcon(metrics)} + {name === "unknown" && ( )}
    @@ -46,3 +53,27 @@ export default function Statistic({
    ); } + +function getUpdatesAvailableIcon(metrics: Data["metrics"]) { + const filteredMetrics = Object.entries(metrics).filter( + ([key]) => !metricsToShow.includes(key), + ); + const maxMetric = filteredMetrics.reduce((max, current) => { + if (Number(current[1]) > Number(max[1])) { + return current; + } + return max; + }, filteredMetrics[0])[0]; + let color = ""; + switch (maxMetric) { + case "major_updates": + color = "text-red-500"; + break; + case "minor_updates": + color = "text-yellow-500"; + break; + default: + color = "text-blue-500"; + } + return ; +} diff --git a/web/src/types.ts b/web/src/types.ts index 7e65a1d..0c335cf 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -2,7 +2,11 @@ export interface Data { metrics: { monitored_images: number; up_to_date: number; - update_available: number; + updates_available: number; + major_updates: number; + minor_updates: number; + patch_updates: number; + other_updates: number; unknown: number; }; images: Image[]; @@ -16,11 +20,22 @@ export interface Image { repository: string; tag: string; }; - local_digests: string[]; - remote_digest: string; result: { has_update: boolean | null; + info: VersionInfo | DigestInfo | null; error: string | null; }; time: number; } + +interface VersionInfo { + "type": "version", + version_update_type: "major" | "minor" | "patch", + new_version: string +} + +interface DigestInfo { + "type": "digest", + local_digests: string[], + remote_digest: string +} \ No newline at end of file