mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-17 01:23:39 -05:00
Changed how updates are managed after checking (preparing for the new API)
This commit is contained in:
13
src/check.rs
13
src/check.rs
@@ -26,7 +26,7 @@ where
|
||||
}
|
||||
|
||||
/// Returns a list of updates for all images passed in.
|
||||
pub async fn get_updates(images: &[Image], config: &Config) -> Vec<(String, Option<bool>)> {
|
||||
pub async fn get_updates(images: &[Image], config: &Config) -> Vec<Image> {
|
||||
// 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()
|
||||
@@ -80,16 +80,5 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec<(String, Opti
|
||||
// Await all the futures
|
||||
let final_images = join_all(handles).await;
|
||||
|
||||
let mut result: Vec<(String, Option<bool>)> = Vec::with_capacity(images.len());
|
||||
final_images
|
||||
.iter()
|
||||
.for_each(|image| match &image.remote_digest {
|
||||
Some(digest) => {
|
||||
let has_update = !image.local_digests.as_ref().unwrap().contains(digest);
|
||||
result.push((image.reference.clone(), Some(has_update)))
|
||||
}
|
||||
None => result.push((image.reference.clone(), None)),
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -2,44 +2,41 @@ use std::time::Duration;
|
||||
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
use crate::utils::{sort_update_vec, to_json};
|
||||
use crate::{image::{Image, Status}, utils::{sort_image_vec, to_simple_json}};
|
||||
|
||||
pub fn print_updates(updates: &[(String, Option<bool>)], icons: &bool) {
|
||||
let sorted_updates = sort_update_vec(updates);
|
||||
pub fn print_updates(updates: &[Image], icons: &bool) {
|
||||
let sorted_images = sort_image_vec(updates);
|
||||
let term_width: usize = termsize::get()
|
||||
.unwrap_or(termsize::Size { rows: 24, cols: 80 })
|
||||
.cols as usize;
|
||||
for update in sorted_updates {
|
||||
let description = match update.1 {
|
||||
Some(true) => "Update available",
|
||||
Some(false) => "Up to date",
|
||||
None => "Unknown",
|
||||
};
|
||||
for image in sorted_images {
|
||||
let has_update = image.has_update();
|
||||
let description = has_update.to_string();
|
||||
let icon = if *icons {
|
||||
match update.1 {
|
||||
Some(true) => "\u{f0aa} ",
|
||||
Some(false) => "\u{f058} ",
|
||||
None => "\u{f059} ",
|
||||
match has_update {
|
||||
Status::UpdateAvailable => "\u{f0aa} ",
|
||||
Status::UpToDate => "\u{f058} ",
|
||||
Status::Unknown(_) => "\u{f059} ",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let color = match update.1 {
|
||||
Some(true) => "\u{001b}[38;5;12m",
|
||||
Some(false) => "\u{001b}[38;5;2m",
|
||||
None => "\u{001b}[38;5;8m",
|
||||
let color = match has_update {
|
||||
Status::UpdateAvailable => "\u{001b}[38;5;12m",
|
||||
Status::UpToDate => "\u{001b}[38;5;2m",
|
||||
Status::Unknown(_) => "\u{001b}[38;5;8m",
|
||||
};
|
||||
let dynamic_space =
|
||||
" ".repeat(term_width - description.len() - icon.len() - update.0.len());
|
||||
" ".repeat(term_width - description.len() - icon.len() - image.reference.len());
|
||||
println!(
|
||||
"{}{}{}{}{}\u{001b}[0m",
|
||||
color, icon, update.0, dynamic_space, description
|
||||
color, icon, image.reference, dynamic_space, description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_raw_updates(updates: &[(String, Option<bool>)]) {
|
||||
println!("{}", json::stringify(to_json(updates)));
|
||||
pub fn print_raw_updates(updates: &[Image]) {
|
||||
println!("{}", json::stringify(to_simple_json(updates)));
|
||||
}
|
||||
|
||||
pub struct Spinner {
|
||||
|
||||
66
src/image.rs
66
src/image.rs
@@ -6,7 +6,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)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Image {
|
||||
pub reference: String,
|
||||
pub registry: Option<String>,
|
||||
@@ -14,6 +14,7 @@ pub struct Image {
|
||||
pub tag: Option<String>,
|
||||
pub local_digests: Option<Vec<String>>,
|
||||
pub remote_digest: Option<String>,
|
||||
pub error: Option<String>
|
||||
}
|
||||
|
||||
impl Image {
|
||||
@@ -22,9 +23,6 @@ impl Image {
|
||||
if !image.repo_tags.is_empty() && !image.repo_digests.is_empty() {
|
||||
let mut image = Image {
|
||||
reference: image.repo_tags[0].clone(),
|
||||
registry: None,
|
||||
repository: None,
|
||||
tag: None,
|
||||
local_digests: Some(
|
||||
image
|
||||
.repo_digests
|
||||
@@ -33,7 +31,7 @@ impl Image {
|
||||
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
|
||||
.collect(),
|
||||
),
|
||||
remote_digest: None,
|
||||
..Default::default()
|
||||
};
|
||||
let (registry, repository, tag) = image.split();
|
||||
image.registry = Some(registry);
|
||||
@@ -53,9 +51,6 @@ impl Image {
|
||||
{
|
||||
let mut image = Image {
|
||||
reference: image.repo_tags.as_ref().unwrap()[0].clone(),
|
||||
registry: None,
|
||||
repository: None,
|
||||
tag: None,
|
||||
local_digests: Some(
|
||||
image
|
||||
.repo_digests
|
||||
@@ -65,7 +60,7 @@ impl Image {
|
||||
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
|
||||
.collect(),
|
||||
),
|
||||
remote_digest: None,
|
||||
..Default::default()
|
||||
};
|
||||
let (registry, repository, tag) = image.split();
|
||||
image.registry = Some(registry);
|
||||
@@ -108,6 +103,31 @@ impl Image {
|
||||
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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Regex to match Docker image references against, so registry, repository and tag can be extracted.
|
||||
@@ -117,3 +137,31 @@ static RE: Lazy<Regex> = Lazy::new(|| {
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// Enum for image status
|
||||
pub enum Status {
|
||||
UpToDate,
|
||||
UpdateAvailable,
|
||||
Unknown(String)
|
||||
}
|
||||
|
||||
impl ToString for Status {
|
||||
fn to_string(&self) -> String {
|
||||
match &self {
|
||||
Self::UpToDate => "Up to date",
|
||||
Self::UpdateAvailable => "Update available",
|
||||
Self::Unknown(_) => "Unknown"
|
||||
}.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Status {
|
||||
// Converts the Status into an Option<bool> (useful for JSON serialization)
|
||||
pub fn to_option_bool(&self) -> Option<bool> {
|
||||
match &self {
|
||||
Self::UpdateAvailable => Some(true),
|
||||
Self::UpToDate => Some(false),
|
||||
Self::Unknown(_) => None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,14 +82,15 @@ pub async fn get_latest_digest(
|
||||
let status = response.status();
|
||||
if status == 401 {
|
||||
if token.is_some() {
|
||||
warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry.as_ref().unwrap(), token.unwrap());
|
||||
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() }
|
||||
} else {
|
||||
warn!("Registry requires authentication");
|
||||
return Image { remote_digest: None, error: Some("Registry requires authentication".to_string()), ..image.clone() }
|
||||
}
|
||||
return Image { remote_digest: None, ..image.clone() }
|
||||
} else if status == 404 {
|
||||
warn!("Image {:?} not found", &image);
|
||||
return Image { remote_digest: None, ..image.clone() }
|
||||
return Image { remote_digest: None, error: Some("Image not found".to_string()), ..image.clone() }
|
||||
} else {
|
||||
response
|
||||
}
|
||||
@@ -97,7 +98,7 @@ pub async fn get_latest_digest(
|
||||
Err(e) => {
|
||||
if e.is_connect() {
|
||||
warn!("Connection to registry failed.");
|
||||
return Image { remote_digest: None, ..image.clone() }
|
||||
return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), ..image.clone() }
|
||||
} else {
|
||||
error!("Unexpected error: {}", e.to_string())
|
||||
}
|
||||
@@ -140,7 +141,7 @@ pub async fn get_token(
|
||||
Ok(response) => match response.text().await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
error!("Failed to parse response into string!\n{}", e)
|
||||
error!("Failed to parse registry response into string!\n{}", e)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use chrono::Local;
|
||||
use json::JsonValue;
|
||||
use liquid::{object, Object};
|
||||
use liquid::{object, Object, ValueView};
|
||||
use tokio::sync::Mutex;
|
||||
use xitca_web::{
|
||||
body::ResponseBody,
|
||||
@@ -14,7 +14,7 @@ use xitca_web::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, info, utils::{sort_update_vec, to_json}
|
||||
check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, image::Image, info, utils::{sort_image_vec, to_simple_json}
|
||||
};
|
||||
|
||||
const HTML: &str = include_str!("static/index.html");
|
||||
@@ -90,7 +90,7 @@ async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
|
||||
|
||||
struct ServerData {
|
||||
template: String,
|
||||
raw_updates: Vec<(String, Option<bool>)>,
|
||||
raw_updates: Vec<Image>,
|
||||
json: JsonValue,
|
||||
config: Config,
|
||||
theme: &'static str,
|
||||
@@ -117,7 +117,7 @@ impl ServerData {
|
||||
info!("Refreshing data");
|
||||
}
|
||||
let images = get_images_from_docker_daemon(&self.config, &None).await;
|
||||
let updates = sort_update_vec(&get_updates(&images, &self.config).await);
|
||||
let updates = sort_image_vec(&get_updates(&images, &self.config).await);
|
||||
let end = Local::now().timestamp_millis();
|
||||
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
|
||||
self.raw_updates = updates;
|
||||
@@ -129,12 +129,9 @@ impl ServerData {
|
||||
let images = self
|
||||
.raw_updates
|
||||
.iter()
|
||||
.map(|(name, has_update)| match has_update {
|
||||
Some(v) => object!({"name": name, "has_update": v.to_string()}), // Liquid kinda thinks false == nil, so we'll be comparing strings from now on
|
||||
None => object!({"name": name, "has_update": "null"}),
|
||||
})
|
||||
.map(|image| object!({"name": image.reference, "has_update": image.has_update().to_option_bool().to_value()}),)
|
||||
.collect::<Vec<Object>>();
|
||||
self.json = to_json(&self.raw_updates);
|
||||
self.json = to_simple_json(&self.raw_updates);
|
||||
let last_updated = Local::now();
|
||||
self.json["last_updated"] = last_updated
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
|
||||
111
src/utils.rs
111
src/utils.rs
@@ -2,41 +2,53 @@ 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_update_vec(updates: &[(String, Option<bool>)]) -> Vec<(String, Option<bool>)> {
|
||||
pub fn sort_image_vec(updates: &[Image]) -> Vec<Image> {
|
||||
let mut sorted_updates = updates.to_vec();
|
||||
sorted_updates.sort_unstable_by(|a, b| match (a.1, b.1) {
|
||||
(Some(c), Some(d)) => {
|
||||
if c == d {
|
||||
a.0.cmp(&b.0)
|
||||
} else {
|
||||
(!c).cmp(&!d)
|
||||
}
|
||||
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)
|
||||
}
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.0.cmp(&b.0),
|
||||
});
|
||||
sorted_updates.to_vec()
|
||||
}
|
||||
|
||||
pub fn to_json(updates: &[(String, Option<bool>)]) -> JsonValue {
|
||||
/// 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! {}
|
||||
};
|
||||
updates.iter().for_each(|(image, has_update)| {
|
||||
let _ = json_data["images"].insert(image, *has_update);
|
||||
let mut up_to_date = 0;
|
||||
let mut update_available = 0;
|
||||
let mut unknown = 0;
|
||||
updates.iter().for_each(|image| {
|
||||
let has_update = image.has_update();
|
||||
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 up_to_date = updates
|
||||
.iter()
|
||||
.filter(|&(_, value)| *value == Some(false))
|
||||
.count();
|
||||
let update_available = updates
|
||||
.iter()
|
||||
.filter(|&(_, value)| *value == Some(true))
|
||||
.count();
|
||||
let unknown = updates.iter().filter(|&(_, value)| value.is_none()).count();
|
||||
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);
|
||||
@@ -84,3 +96,56 @@ pub fn new_reqwest_client() -> ClientWithMiddleware {
|
||||
))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user