m/cup
1
0
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:
Sergio
2024-10-25 11:32:59 +03:00
parent e1eaf63f1c
commit 8ab073d562
6 changed files with 176 additions and 79 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}
}
}

View File

@@ -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) => {

View File

@@ -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)

View File

@@ -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);
}
}