diff --git a/src/check.rs b/src/check.rs index f9438b5..d01104a 100644 --- a/src/check.rs +++ b/src/check.rs @@ -2,7 +2,10 @@ use futures::future::join_all; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ - config::Config, image::Image, registry::{check_auth, get_token}, utils::new_reqwest_client + config::Config, + image::Image, + registry::{check_auth, get_token}, + utils::new_reqwest_client, }; use crate::registry::get_latest_digest; @@ -78,7 +81,5 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec { handles.push(future); } // Await all the futures - let final_images = join_all(handles).await; - - final_images + join_all(handles).await } diff --git a/src/config.rs b/src/config.rs index cc4aaac..f9e2cb8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,7 +28,7 @@ impl Config { authentication: FxHashMap::default(), theme: Theme::Default, insecure_registries: Vec::with_capacity(0), - socket: None + socket: None, } } /// Reads the config from the file path provided and returns the parsed result. diff --git a/src/docker.rs b/src/docker.rs index 06c67c6..bd97199 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::{error, image::Image, config::Config}; +use crate::{config::Config, error, image::Image}; fn create_docker_client(socket: Option) -> Docker { let client: Result = match socket { diff --git a/src/formatting.rs b/src/formatting.rs index af3521b..7888d98 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -2,7 +2,10 @@ use std::time::Duration; use indicatif::{ProgressBar, ProgressStyle}; -use crate::{image::{Image, Status}, utils::{sort_image_vec, to_simple_json}}; +use crate::{ + image::{Image, Status}, + utils::{sort_image_vec, to_simple_json}, +}; pub fn print_updates(updates: &[Image], icons: &bool) { let sorted_images = sort_image_vec(updates); diff --git a/src/image.rs b/src/image.rs index 84b4bc4..b8404cb 100644 --- a/src/image.rs +++ b/src/image.rs @@ -1,4 +1,7 @@ +use std::fmt::Display; + use bollard::models::{ImageInspect, ImageSummary}; +use json::{object, JsonValue}; use once_cell::sync::Lazy; use regex::Regex; @@ -6,7 +9,7 @@ use crate::error; /// 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)] +#[derive(Clone, Debug, PartialEq, Default)] pub struct Image { pub reference: String, pub registry: Option, @@ -14,7 +17,8 @@ pub struct Image { pub tag: Option, pub local_digests: Option>, pub remote_digest: Option, - pub error: Option + pub error: Option, + pub time_ms: i64 } impl Image { @@ -108,24 +112,38 @@ impl Image { pub fn has_update(&self) -> Status { if self.error.is_some() { Status::Unknown(self.error.clone().unwrap()) - } else if self.local_digests.as_ref().unwrap().contains(&self.remote_digest.as_ref().unwrap()) { + } else if self + .local_digests + .as_ref() + .unwrap() + .contains(self.remote_digest.as_ref().unwrap()) + { Status::UpToDate } else { Status::UpdateAvailable } } -} -impl Default for Image { - fn default() -> Self { - Self { - reference: String::new(), - registry: None, - repository: None, - tag: None, - local_digests: None, - remote_digest: None, - error: None + /// 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 } } } @@ -142,16 +160,16 @@ static RE: Lazy = Lazy::new(|| { pub enum Status { UpToDate, UpdateAvailable, - Unknown(String) + Unknown(String), } -impl ToString for Status { - fn to_string(&self) -> String { - match &self { +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::Unknown(_) => "Unknown" - }.to_string() + Self::Unknown(_) => "Unknown", + }) } } @@ -161,7 +179,7 @@ impl Status { match &self { Self::UpdateAvailable => Some(true), Self::UpToDate => Some(false), - Self::Unknown(_) => None + Self::Unknown(_) => None, } } -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 5b62500..e181f9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use check::get_updates; -use chrono::Local; use clap::{Parser, Subcommand}; use config::Config; use docker::get_images_from_docker_daemon; @@ -7,6 +6,7 @@ use docker::get_images_from_docker_daemon; use formatting::{print_raw_updates, print_updates, Spinner}; #[cfg(feature = "server")] use server::serve; +use utils::timestamp; use std::path::PathBuf; pub mod check; @@ -67,9 +67,8 @@ async fn main() { path => Some(PathBuf::from(path)), }; let mut config = Config::new().load(cfg_path); - match cli.socket { - Some(socket) => config.socket = Some(socket), - None => () + if let Some(socket) = cli.socket { + config.socket = Some(socket) } match &cli.command { #[cfg(feature = "cli")] @@ -78,7 +77,7 @@ async fn main() { icons, raw, }) => { - let start = Local::now().timestamp_millis(); + let start = timestamp(); let images = get_images_from_docker_daemon(&config, references).await; match raw { true => { @@ -89,7 +88,7 @@ async fn main() { let spinner = Spinner::new(); let updates = get_updates(&images, &config).await; spinner.succeed(); - let end = Local::now().timestamp_millis(); + let end = timestamp(); print_updates(&updates, icons); info!("✨ Checked {} images in {}ms", updates.len(), end - start); } diff --git a/src/registry.rs b/src/registry.rs index bd72b14..68d52dd 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -3,7 +3,7 @@ use json::JsonValue; use http_auth::parse_challenges; use reqwest_middleware::ClientWithMiddleware; -use crate::{config::Config, error, image::Image, warn}; +use crate::{config::Config, error, image::Image, utils::timestamp, warn}; pub async fn check_auth( registry: &str, @@ -58,7 +58,10 @@ pub async fn get_latest_digest( config: &Config, client: &ClientWithMiddleware, ) -> Image { - let protocol = if config.insecure_registries.contains(&image.registry.clone().unwrap()) + let start = timestamp(); + let protocol = if config + .insecure_registries + .contains(&image.registry.clone().unwrap()) { "http" } else { @@ -83,14 +86,14 @@ pub async fn get_latest_digest( 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())), ..image.clone() } + return Image { remote_digest: None, 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()), ..image.clone() } + return Image { remote_digest: None, 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()), ..image.clone() } + return Image { remote_digest: None, error: Some("Image not found".to_string()), time_ms: timestamp() - start, ..image.clone() } } else { response } @@ -98,7 +101,7 @@ pub async fn get_latest_digest( Err(e) => { if e.is_connect() { warn!("Connection to registry failed."); - return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), ..image.clone() } + return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), time_ms: timestamp() - start, ..image.clone() } } else { error!("Unexpected error: {}", e.to_string()) } @@ -107,6 +110,7 @@ pub async fn get_latest_digest( match raw_response.headers().get("docker-content-digest") { Some(digest) => Image { remote_digest: Some(digest.to_str().unwrap().to_string()), + time_ms: timestamp() - start, ..image.clone() }, None => error!( diff --git a/src/server.rs b/src/server.rs index 94c98ba..5aeedd5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -14,7 +14,12 @@ use xitca_web::{ }; use crate::{ - check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, image::Image, info, utils::{sort_image_vec, to_simple_json} + 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}, }; const HTML: &str = include_str!("static/index.html"); @@ -31,7 +36,8 @@ pub async fn serve(port: &u16, config: &Config) -> std::io::Result<()> { App::new() .with_state(Arc::new(Mutex::new(data))) .at("/", get(handler_service(_static))) - .at("/json", get(handler_service(json))) + .at("/api/v1/simple", get(handler_service(api_simple))) + .at("/api/v1/full", get(handler_service(api_full))) .at("/refresh", get(handler_service(refresh))) .at("/*", get(handler_service(_static))) .enclosed(Logger::new()) @@ -77,9 +83,15 @@ async fn _static(data: StateRef<'_, Arc>>, path: PathRef<'_>) } } -async fn json(data: StateRef<'_, Arc>>) -> WebResponse { +async fn api_simple(data: StateRef<'_, Arc>>) -> WebResponse { WebResponse::new(ResponseBody::from(json::stringify( - data.lock().await.json.clone(), + data.lock().await.simple_json.clone(), + ))) +} + +async fn api_full(data: StateRef<'_, Arc>>) -> WebResponse { + WebResponse::new(ResponseBody::from(json::stringify( + data.lock().await.full_json.clone(), ))) } @@ -91,7 +103,8 @@ async fn refresh(data: StateRef<'_, Arc>>) -> WebResponse { struct ServerData { template: String, raw_updates: Vec, - json: JsonValue, + simple_json: JsonValue, + full_json: JsonValue, config: Config, theme: &'static str, } @@ -101,10 +114,8 @@ impl ServerData { let mut s = Self { config: config.clone(), template: String::new(), - json: json::object! { - metrics: json::object! {}, - images: json::object! {}, - }, + simple_json: JsonValue::Null, + full_json: JsonValue::Null, raw_updates: Vec::new(), theme: "neutral", }; @@ -112,13 +123,13 @@ impl ServerData { s } async fn refresh(&mut self) { - let start = Local::now().timestamp_millis(); + let start = timestamp(); if !self.raw_updates.is_empty() { info!("Refreshing data"); } let images = get_images_from_docker_daemon(&self.config, &None).await; let updates = sort_image_vec(&get_updates(&images, &self.config).await); - let end = Local::now().timestamp_millis(); + let end = timestamp(); info!("✨ Checked {} images in {}ms", updates.len(), end - start); self.raw_updates = updates; let template = liquid::ParserBuilder::with_stdlib() @@ -131,18 +142,23 @@ impl ServerData { .iter() .map(|image| object!({"name": image.reference, "has_update": image.has_update().to_option_bool().to_value()}),) .collect::>(); - self.json = to_simple_json(&self.raw_updates); + self.simple_json = to_simple_json(&self.raw_updates); + self.full_json = to_full_json(&self.raw_updates); let last_updated = Local::now(); - self.json["last_updated"] = last_updated + self.simple_json["last_updated"] = last_updated + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + .to_string() + .into(); + self.full_json["last_updated"] = last_updated .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) .to_string() .into(); self.theme = match &self.config.theme { Theme::Default => "neutral", - Theme::Blue => "gray" + Theme::Blue => "gray", }; let globals = object!({ - "metrics": [{"name": "Monitored images", "value": self.json["metrics"]["monitored_images"].as_usize()}, {"name": "Up to date", "value": self.json["metrics"]["up_to_date"].as_usize()}, {"name": "Updates available", "value": self.json["metrics"]["update_available"].as_usize()}, {"name": "Unknown", "value": self.json["metrics"]["unknown"].as_usize()}], + "metrics": [{"name": "Monitored images", "value": self.simple_json["metrics"]["monitored_images"].as_usize()}, {"name": "Up to date", "value": self.simple_json["metrics"]["up_to_date"].as_usize()}, {"name": "Updates available", "value": self.simple_json["metrics"]["update_available"].as_usize()}, {"name": "Unknown", "value": self.simple_json["metrics"]["unknown"].as_usize()}], "images": images, "last_updated": last_updated.format("%Y-%m-%d %H:%M:%S").to_string(), "theme": &self.theme diff --git a/src/utils.rs b/src/utils.rs index 176a4ae..9f296f4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,4 @@ +use chrono::Local; use json::{object, JsonValue}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware}; @@ -8,29 +9,23 @@ use crate::image::{Image, Status}; 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::UpdateAvailable) => a.reference.cmp(&b.reference), + (Status::UpdateAvailable, Status::UpToDate | Status::Unknown(_)) => { + std::cmp::Ordering::Less } - (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::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) + (Status::Unknown(_), Status::UpdateAvailable | Status::UpToDate) => { + std::cmp::Ordering::Greater } + (Status::Unknown(_), Status::Unknown(_)) => a.reference.cmp(&b.reference), }); sorted_updates.to_vec() } -/// 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: object! {}, - images: object! {} - }; +/// 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 unknown = 0; @@ -39,23 +34,43 @@ pub fn to_simple_json(updates: &[Image]) -> JsonValue { match has_update { Status::UpdateAvailable => { update_available += 1; - }, + } Status::UpToDate => { up_to_date += 1; - }, + } Status::Unknown(_) => { unknown += 1; } }; - let _ = json_data["images"].insert(&image.reference, has_update.to_option_bool()); }); - let _ = json_data["metrics"].insert("monitored_images", updates.len()); - let _ = json_data["metrics"].insert("up_to_date", up_to_date); - let _ = json_data["metrics"].insert("update_available", update_available); - let _ = json_data["metrics"].insert("unknown", unknown); + object! { + monitored_images: updates.len(), + up_to_date: up_to_date, + update_available: update_available, + 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) @@ -89,6 +104,7 @@ macro_rules! debug { }) } +/// Creates a new reqwest client with automatic retries pub fn new_reqwest_client() -> ClientWithMiddleware { ClientBuilder::new(reqwest::Client::new()) .with(RetryTransientMiddleware::new_with_policy( @@ -97,6 +113,10 @@ pub fn new_reqwest_client() -> ClientWithMiddleware { .build() } +pub fn timestamp() -> i64 { + Local::now().timestamp_millis() +} + #[cfg(test)] mod tests { use super::*; @@ -107,25 +127,39 @@ mod tests { // 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()]), + 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 + 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()]), + 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()]), + 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() }; @@ -139,8 +173,22 @@ mod tests { 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]; + 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); @@ -148,4 +196,4 @@ mod tests { // Check results assert_eq!(sorted_vec, expected_vec); } -} \ No newline at end of file +}