m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-16 09:03:46 -05:00

OMG WE CAN DO SEMVER FOR THE CLI AND THE RESULTS LOOK CORRECT

This commit is contained in:
Sergio
2024-11-01 21:39:15 +02:00
parent 022dc0b2cb
commit c11b5e6432
5 changed files with 278 additions and 64 deletions

View File

@@ -8,8 +8,6 @@ use crate::{
utils::new_reqwest_client,
};
use crate::registry::get_latest_digest;
/// Trait for a type that implements a function `unique` that removes any duplicates.
/// In this case, it will be used for a Vec.
pub trait Unique<T> {
@@ -77,7 +75,7 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec<Image> {
// Loop through images and get the latest digest for each
for image in images {
let token = tokens.get(&image.registry.as_ref().unwrap()).unwrap();
let future = get_latest_digest(image, token.as_ref(), config, &client);
let future = image.check(token.as_ref(), config, &client);
handles.push(future);
}
// Await all the futures

View File

@@ -17,15 +17,17 @@ pub fn print_updates(updates: &[Image], icons: &bool) {
let description = has_update.to_string();
let icon = if *icons {
match has_update {
Status::UpdateAvailable => "\u{f0aa} ",
Status::UpToDate => "\u{f058} ",
Status::Unknown(_) => "\u{f059} ",
_ => "\u{f0aa} ",
}
} else {
""
};
let color = match has_update {
Status::UpdateAvailable => "\u{001b}[38;5;12m",
Status::UpdateAvailable | Status::UpdatePatch => "\u{001b}[38;5;12m",
Status::UpdateMinor => "\u{001b}[38;5;3m",
Status::UpdateMajor => "\u{001b}[38;5;1m",
Status::UpToDate => "\u{001b}[38;5;2m",
Status::Unknown(_) => "\u{001b}[38;5;8m",
};

View File

@@ -1,11 +1,16 @@
use std::fmt::Display;
use std::{cmp::Ordering, fmt::Display};
use bollard::models::{ImageInspect, ImageSummary};
use json::{object, JsonValue};
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest_middleware::ClientWithMiddleware;
use crate::error;
use crate::{
config::Config,
error,
registry::{get_latest_digest, get_latest_tag},
};
/// Image struct that contains all information that may be needed by a function.
/// It's designed to be passed around between functions
@@ -17,6 +22,8 @@ pub struct Image {
pub tag: Option<String>,
pub local_digests: Option<Vec<String>>,
pub remote_digest: Option<String>,
pub semver_tag: Option<SemVer>,
pub latest_remote_tag: Option<SemVer>,
pub error: Option<String>,
pub time_ms: i64,
}
@@ -41,6 +48,7 @@ impl Image {
image.registry = Some(registry);
image.repository = Some(repository);
image.tag = Some(tag);
image.semver_tag = image.get_version();
return Some(image);
}
@@ -70,6 +78,7 @@ impl Image {
image.registry = Some(registry);
image.repository = Some(repository);
image.tag = Some(tag);
image.semver_tag = image.get_version();
return Some(image);
}
@@ -112,6 +121,11 @@ impl Image {
pub fn has_update(&self) -> Status {
if self.error.is_some() {
Status::Unknown(self.error.clone().unwrap())
} else if self.latest_remote_tag.is_some() {
self.latest_remote_tag
.as_ref()
.unwrap()
.to_status(self.semver_tag.as_ref().unwrap())
} else if self
.local_digests
.as_ref()
@@ -151,6 +165,19 @@ impl Image {
pub fn get_version(&self) -> Option<SemVer> {
get_version(self.tag.as_ref().unwrap())
}
/// Checks if the image has an update
pub async fn check(
&self,
token: Option<&String>,
config: &Config,
client: &ClientWithMiddleware,
) -> Self {
match &self.semver_tag {
Some(version) => get_latest_tag(self, version, token, config, client).await,
None => get_latest_digest(self, token, config, client).await,
}
}
}
/// Tries to parse the tag into semver parts. Should have been included in impl Image, but that would make the tests more complicated
@@ -179,14 +206,8 @@ pub fn get_version(tag: &str) -> Option<SemVer> {
Some(major) => major.as_str().parse().unwrap(),
None => return None,
};
let minor: i32 = match c.name("minor") {
Some(minor) => minor.as_str().parse().unwrap(),
None => 0,
};
let patch: i32 = match c.name("patch") {
Some(patch) => patch.as_str().parse().unwrap(),
None => 0,
};
let minor: Option<i32> = c.name("minor").map(|minor| minor.as_str().parse().unwrap());
let patch: Option<i32> = c.name("patch").map(|patch| patch.as_str().parse().unwrap());
Some(SemVer {
major,
minor,
@@ -212,9 +233,13 @@ static SEMVER: Lazy<Regex> = Lazy::new(|| {
});
/// Enum for image status
#[derive(Ord, Eq, PartialEq, PartialOrd)]
pub enum Status {
UpToDate,
UpdateMajor,
UpdateMinor,
UpdatePatch,
UpdateAvailable,
UpToDate,
Unknown(String),
}
@@ -223,6 +248,9 @@ impl Display for Status {
f.write_str(match &self {
Self::UpToDate => "Up to date",
Self::UpdateAvailable => "Update available",
Self::UpdateMajor => "Major update",
Self::UpdateMinor => "Minor update",
Self::UpdatePatch => "Patch update",
Self::Unknown(_) => "Unknown",
})
}
@@ -232,18 +260,74 @@ impl Status {
// Converts the Status into an Option<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,
_ => Some(true),
}
}
}
#[derive(Debug, PartialEq)]
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct SemVer {
major: i32,
minor: i32,
patch: i32,
pub major: i32,
pub minor: Option<i32>,
pub patch: Option<i32>,
}
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<Ordering> {
Some(self.cmp(other))
}
}
#[cfg(test)]
@@ -253,21 +337,21 @@ mod tests {
#[test]
#[rustfmt::skip]
fn semver() {
assert_eq!(get_version("5.3.2" ).unwrap(), SemVer { major: 5, minor: 3, patch: 2 });
assert_eq!(get_version("14" ).unwrap(), SemVer { major: 14, minor: 0, patch: 0 });
assert_eq!(get_version("v0.107.53" ).unwrap(), SemVer { major: 0, minor: 107, patch: 53 });
assert_eq!(get_version("12-alpine" ).unwrap(), SemVer { major: 12, minor: 0, patch: 0 });
assert_eq!(get_version("0.9.5-nginx" ).unwrap(), SemVer { major: 0, minor: 9, patch: 5 });
assert_eq!(get_version("v27.0" ).unwrap(), SemVer { major: 27, minor: 0, patch: 0 });
assert_eq!(get_version("16.1" ).unwrap(), SemVer { major: 16, minor: 1, patch: 0 });
assert_eq!(get_version("version-1.5.6" ).unwrap(), SemVer { major: 1, minor: 5, patch: 6 });
assert_eq!(get_version("15.4-alpine" ).unwrap(), SemVer { major: 15, minor: 4, patch: 0 });
assert_eq!(get_version("pg14-v0.2.0" ).unwrap(), SemVer { major: 0, minor: 2, patch: 0 });
assert_eq!(get_version("18-jammy-full.s6-v0.88.0").unwrap(), SemVer { major: 0, minor: 88, patch: 0 });
assert_eq!(get_version("fpm-2.1.0-prod" ).unwrap(), SemVer { major: 2, minor: 1, patch: 0 });
assert_eq!(get_version("7.3.3.50" ).unwrap(), SemVer { major: 7, minor: 3, patch: 3 });
assert_eq!(get_version("1.21.11-0" ).unwrap(), SemVer { major: 1, minor: 21, patch: 11 });
assert_eq!(get_version("4.1.2.1-full" ).unwrap(), SemVer { major: 4, minor: 1, patch: 2 });
assert_eq!(get_version("v4.0.3-ls215" ).unwrap(), SemVer { major: 4, minor: 0, patch: 3 });
assert_eq!(get_version("5.3.2" ), Some(SemVer { major: 5, minor: Some(3), patch: Some(2) }));
assert_eq!(get_version("14" ), Some(SemVer { major: 14, minor: Some(0), patch: Some(0) }));
assert_eq!(get_version("v0.107.53" ), Some(SemVer { major: 0, minor: Some(107), patch: Some(53) }));
assert_eq!(get_version("12-alpine" ), Some(SemVer { major: 12, minor: Some(0), patch: Some(0) }));
assert_eq!(get_version("0.9.5-nginx" ), Some(SemVer { major: 0, minor: Some(9), patch: Some(5) }));
assert_eq!(get_version("v27.0" ), Some(SemVer { major: 27, minor: Some(0), patch: Some(0) }));
assert_eq!(get_version("16.1" ), Some(SemVer { major: 16, minor: Some(1), patch: Some(0) }));
assert_eq!(get_version("version-1.5.6" ), Some(SemVer { major: 1, minor: Some(5), patch: Some(6) }));
assert_eq!(get_version("15.4-alpine" ), Some(SemVer { major: 15, minor: Some(4), patch: Some(0) }));
assert_eq!(get_version("pg14-v0.2.0" ), Some(SemVer { major: 0, minor: Some(2), patch: Some(0) }));
assert_eq!(get_version("18-jammy-full.s6-v0.88.0"), Some(SemVer { major: 0, minor: Some(88), patch: Some(0) }));
assert_eq!(get_version("fpm-2.1.0-prod" ), Some(SemVer { major: 2, minor: Some(1), patch: Some(0) }));
assert_eq!(get_version("7.3.3.50" ), Some(SemVer { major: 7, minor: Some(3), patch: Some(3) }));
assert_eq!(get_version("1.21.11-0" ), Some(SemVer { major: 1, minor: Some(21), patch: Some(11) }));
assert_eq!(get_version("4.1.2.1-full" ), Some(SemVer { major: 4, minor: Some(1), patch: Some(2) }));
assert_eq!(get_version("v4.0.3-ls215" ), Some(SemVer { major: 4, minor: Some(0), patch: Some(3) }));
}
}

View File

@@ -3,7 +3,13 @@ use json::JsonValue;
use http_auth::parse_challenges;
use reqwest_middleware::ClientWithMiddleware;
use crate::{config::Config, error, image::Image, utils::timestamp, warn};
use crate::{
config::Config,
error,
image::{get_version, Image, SemVer},
utils::timestamp,
warn,
};
pub async fn check_auth(
registry: &str,
@@ -85,23 +91,23 @@ pub async fn get_latest_digest(
let status = response.status();
if status == 401 {
if token.is_some() {
warn!("Failed to authenticate to registry {} with token provided!\n{}", &image.registry.as_ref().unwrap(), token.unwrap());
return Image { remote_digest: None, error: Some(format!("Authentication token \"{}\" was not accepted", token.unwrap())), time_ms: timestamp() - start, ..image.clone() }
warn!("Failed to authenticate to registry {} with token provided!\n{}", image.registry.as_ref().unwrap(), token.unwrap());
return Image { error: Some(format!("Authentication token \"{}\" was not accepted", token.unwrap())), time_ms: timestamp() - start, ..image.clone() }
} else {
warn!("Registry requires authentication");
return Image { remote_digest: None, error: Some("Registry requires authentication".to_string()), time_ms: timestamp() - start, ..image.clone() }
warn!("Registry {} requires authentication", image.registry.as_ref().unwrap());
return Image { error: Some("Registry requires authentication".to_string()), time_ms: timestamp() - start, ..image.clone() }
}
} else if status == 404 {
warn!("Image {:?} not found", &image);
return Image { remote_digest: None, error: Some("Image not found".to_string()), time_ms: timestamp() - start, ..image.clone() }
return Image { error: Some("Image not found".to_string()), time_ms: timestamp() - start, ..image.clone() }
} else {
response
}
},
Err(e) => {
if e.is_connect() {
warn!("Connection to registry failed.");
return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), time_ms: timestamp() - start, ..image.clone() }
warn!("Connection to registry {} failed.", image.registry.as_ref().unwrap());
return Image { error: Some("Connection to registry failed".to_string()), time_ms: timestamp() - start, ..image.clone() }
} else {
error!("Unexpected error: {}", e.to_string())
}
@@ -134,9 +140,7 @@ pub async fn get_token(
image.repository.as_ref().unwrap()
);
}
let mut base_request = client
.get(&final_url)
.header("Accept", "application/vnd.oci.image.index.v1+json"); // Seems to be unnecessary. Will probably remove in the future
let mut base_request = client.get(&final_url);
base_request = match credentials {
Some(creds) => base_request.header("Authorization", &format!("Basic {}", creds)),
None => base_request,
@@ -165,6 +169,129 @@ pub async fn get_token(
parsed_token_response["token"].to_string()
}
pub async fn get_latest_tag(
image: &Image,
base: &SemVer,
token: Option<&String>,
config: &Config,
client: &ClientWithMiddleware,
) -> Image {
let start = timestamp();
// Start creating request
let protocol = if config
.insecure_registries
.contains(&image.registry.clone().unwrap())
{
"http"
} else {
"https"
};
let mut request = client.get(format!(
"{}://{}/v2/{}/tags/list",
protocol,
&image.registry.as_ref().unwrap(),
&image.repository.as_ref().unwrap(),
));
if let Some(t) = token {
request = request.header("Authorization", &format!("Bearer {}", t));
}
// Send request
let raw_response = match request.header("Accept", "application/json").send().await {
Ok(response) => {
let status = response.status();
if status == 401 {
if token.is_some() {
warn!(
"Failed to authenticate to registry {} with token provided!\n{}",
image.registry.as_ref().unwrap(),
token.unwrap()
);
return Image {
error: Some(format!(
"Authentication token \"{}\" was not accepted",
token.unwrap()
)),
time_ms: timestamp() - start,
..image.clone()
};
} else {
warn!(
"Registry {} requires authentication",
image.registry.as_ref().unwrap()
);
return Image {
error: Some("Registry requires authentication".to_string()),
time_ms: timestamp() - start,
..image.clone()
};
}
} else if status == 404 {
warn!("Image {:?} not found", &image);
return Image {
error: Some("Image not found".to_string()),
time_ms: timestamp() - start,
..image.clone()
};
} else {
match response.text().await {
Ok(res) => res,
Err(e) => {
error!("Failed to parse registry response into string!\n{}", e)
}
}
}
}
Err(e) => {
if e.is_connect() {
warn!(
"Connection to registry {} failed.",
image.registry.as_ref().unwrap()
);
return Image {
error: Some("Connection to registry failed".to_string()),
time_ms: timestamp() - start,
..image.clone()
};
} else {
error!("Unexpected error: {}", e.to_string())
}
}
};
let parsed_response: JsonValue = match json::parse(&raw_response) {
Ok(parsed) => parsed,
Err(e) => {
error!("Failed to parse server response\n{}", e)
}
};
let tag = parsed_response["tags"]
.members()
.filter_map(|tag| get_version(&tag.to_string()))
.filter(|tag| match (base.minor, tag.minor) {
(Some(_), Some(_)) | (None, None) => {
matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None))
}
_ => false,
})
.max();
match tag {
Some(t) => {
if t == *base {
// Tags are equal so we'll compare digests
get_latest_digest(image, token, config, client).await
} else {
Image {
latest_remote_tag: Some(t),
time_ms: timestamp() - start,
..image.clone()
}
}
}
None => unreachable!(),
}
}
fn parse_www_authenticate(www_auth: &str) -> String {
let challenges = parse_challenges(www_auth).unwrap();
if !challenges.is_empty() {

View File

@@ -8,32 +8,32 @@ use crate::image::{Image, Status};
/// Sorts the update vector alphabetically and where Some(true) > Some(false) > None
pub fn sort_image_vec(updates: &[Image]) -> Vec<Image> {
let mut sorted_updates = updates.to_vec();
sorted_updates.sort_unstable_by(|a, b| match (a.has_update(), b.has_update()) {
(Status::UpdateAvailable, Status::UpdateAvailable) => a.reference.cmp(&b.reference),
(Status::UpdateAvailable, Status::UpToDate | Status::Unknown(_)) => {
std::cmp::Ordering::Less
}
(Status::UpToDate, Status::UpdateAvailable) => std::cmp::Ordering::Greater,
(Status::UpToDate, Status::UpToDate) => a.reference.cmp(&b.reference),
(Status::UpToDate, Status::Unknown(_)) => std::cmp::Ordering::Less,
(Status::Unknown(_), Status::UpdateAvailable | Status::UpToDate) => {
std::cmp::Ordering::Greater
}
(Status::Unknown(_), Status::Unknown(_)) => a.reference.cmp(&b.reference),
});
sorted_updates.sort_unstable_by_key(|img| img.has_update());
sorted_updates.to_vec()
}
/// Helper function to get metrics used in JSON output
pub fn get_metrics(updates: &[Image]) -> JsonValue {
let mut up_to_date = 0;
let mut update_available = 0;
let mut major_updates = 0;
let mut minor_updates = 0;
let mut patch_updates = 0;
let mut other_updates = 0;
let mut unknown = 0;
updates.iter().for_each(|image| {
let has_update = image.has_update();
match has_update {
Status::UpdateMajor => {
major_updates += 1;
}
Status::UpdateMinor => {
minor_updates += 1;
}
Status::UpdatePatch => {
patch_updates += 1;
}
Status::UpdateAvailable => {
update_available += 1;
other_updates += 1;
}
Status::UpToDate => {
up_to_date += 1;
@@ -46,7 +46,10 @@ pub fn get_metrics(updates: &[Image]) -> JsonValue {
object! {
monitored_images: updates.len(),
up_to_date: up_to_date,
update_available: update_available,
major_updates: major_updates,
minor_updates: minor_updates,
patch_updates: patch_updates,
other_updates: other_updates,
unknown: unknown
}
}