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

Nearly complete versioning support. Fixed old bugs where not all tags were fetched.

This commit is contained in:
Sergio
2024-11-15 13:21:30 +02:00
parent c11b5e6432
commit d94abecf35
30 changed files with 1288 additions and 962 deletions

20
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"]

View File

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

View File

@@ -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<T> {
fn unique(&mut self) -> Vec<T>;
}
impl<T> Unique<T> for Vec<T>
where
T: Clone + Eq + std::hash::Hash,
{
/// Remove duplicates from Vec
fn unique(self: &mut Vec<T>) -> Self {
let mut seen: FxHashSet<T> = 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<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()
.map(|image| image.registry.as_ref().unwrap())
.collect::<Vec<&String>>()
.unique();
.map(|image| &image.registry)
.unique()
.collect::<Vec<&String>>();
// 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<String>> = FxHashMap::default();
let mut tokens: FxHashMap<&str, Option<String>> = 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<Image> {
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);
}

View File

@@ -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<String>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {
@@ -29,7 +29,7 @@ pub async fn get_images_from_docker_daemon(
references: &Option<Vec<String>>,
) -> Vec<Image> {
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

View File

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

31
src/formatting/spinner.rs Normal file
View File

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

131
src/http.rs Normal file
View File

@@ -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<Response, String> {
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<Response, String> {
self.request(url, RequestMethod::GET, headers, ignore_401)
.await
}
pub async fn head(
&self,
url: &str,
headers: Vec<(&str, Option<&str>)>,
) -> Result<Response, String> {
self.request(url, RequestMethod::HEAD, headers, false).await
}
}
impl Default for Client {
fn default() -> Self {
Self::new()
}
}

View File

@@ -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<String>,
pub repository: Option<String>,
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,
}
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<Self> {
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::<Vec<&str>>()[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<Self> {
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::<Vec<&str>>()[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<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
pub fn get_version(tag: &str) -> Option<SemVer> {
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<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,
patch,
})
}
None => None,
}
}
/// Regex to match Docker image references against, so registry, repository and tag can be extracted.
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"^(?P<name>(?:(?P<registry>(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P<repository>[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P<tag>[\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<Regex> = Lazy::new(|| {
Regex::new(r#"(?P<major>0|[1-9]\d*)(?:\.(?P<minor>0|[1-9]\d*))?(?:\.(?P<patch>0|[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<bool> (useful for JSON serialization)
pub fn to_option_bool(&self) -> Option<bool> {
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<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)]
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) }));
}
}

View File

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

View File

@@ -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<String> {
let protocol = if config.insecure_registries.contains(&registry.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<String> {
let protocol = get_protocol(&registry.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") {
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 if status == 200 {
None
} else {
warn!(
"Received unexpected status code {}\nResponse: {}",
status,
r.text().await.unwrap()
);
None
}
}
Err(e) => {
if e.is_connect() {
warn!("Connection to registry {} failed.", &registry);
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() }
}
} 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
}
},
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())
}
},
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(),
};
match raw_response.headers().get("docker-content-digest") {
Some(digest) => Image {
Image {
digest_info: Some(DigestInfo {
remote_digest: Some(digest.to_str().unwrap().to_string()),
time_ms: timestamp() - start,
local_digests,
}),
time_ms: image.time_ms + (timestamp() - start),
..image.clone()
},
}
}
None => error!(
"Server returned invalid response! No docker-content-digest!\n{:#?}",
raw_response
res
),
},
Err(error) => Image {
error: Some(error),
time_ms: image.time_ms + (timestamp() - start),
..image.clone()
},
}
}
@@ -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<Version> = 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()
}
}
};
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 {
// Tags are equal so we'll compare digests
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 {
warn!(
"Registry {} requires authentication",
image.registry.as_ref().unwrap()
);
return Image {
error: Some("Registry requires authentication".to_string()),
time_ms: timestamp() - start,
Image {
version_info: Some(VersionInfo {
current_tag,
latest_remote_tag: Some(t.clone()),
}),
time_ms: image.time_ms + (timestamp() - start),
..image.clone()
}
}
}
None => unreachable!("{:?}", tags),
}
}
pub async fn get_extra_tags(
url: &str,
headers: Vec<(&str, Option<&str>)>,
base: &Version,
client: &Client,
) -> Result<(Vec<Version>, Option<String>), 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,
};
}
} 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"]
let response_json = parse_json(&get_response_body(res).await);
let result = response_json["tags"]
.members()
.filter_map(|tag| get_version(&tag.to_string()))
.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,
})
.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()
.dedup()
.collect();
Ok((result, next_url))
}
}
}
None => unreachable!(),
}
}
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");
Err(message) => Err(message),
}
}

View File

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

166
src/structs/image.rs Normal file
View File

@@ -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<String>,
pub remote_digest: Option<String>,
}
#[derive(Clone, PartialEq)]
#[cfg_attr(test, derive(Debug))]
pub struct VersionInfo {
pub current_tag: Version,
pub latest_remote_tag: Option<Version>,
}
/// 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<DigestInfo>,
pub version_info: Option<VersionInfo>,
pub error: Option<String>,
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<T: InspectData>(image: T) -> Option<Self> {
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::<Vec<&str>>()[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!(),
},
}
}
}

View File

@@ -0,0 +1,26 @@
use bollard::secret::{ImageInspect, ImageSummary};
pub trait InspectData {
fn tags(&self) -> Option<Vec<String>>;
fn digests(&self) -> Option<Vec<String>>;
}
impl InspectData for ImageInspect {
fn tags(&self) -> Option<Vec<String>> {
self.repo_tags.clone()
}
fn digests(&self) -> Option<Vec<String>> {
self.repo_digests.clone()
}
}
impl InspectData for ImageSummary {
fn tags(&self) -> Option<Vec<String>> {
Some(self.repo_tags.clone())
}
fn digests(&self) -> Option<Vec<String>> {
Some(self.repo_digests.clone())
}
}

4
src/structs/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod image;
pub mod inspectdata;
pub mod status;
pub mod version;

36
src/structs/status.rs Normal file
View File

@@ -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<bool> (useful for JSON serialization)
pub fn to_option_bool(&self) -> Option<bool> {
match &self {
Self::UpToDate => Some(false),
Self::Unknown(_) => None,
_ => Some(true),
}
}
}

164
src/structs/version.rs Normal file
View File

@@ -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<Regex> = Lazy::new(|| {
Regex::new(r#"(?P<major>0|[1-9]\d*)(?:\.(?P<minor>0|[1-9]\d*))?(?:\.(?P<patch>0|[1-9]\d*)+)?"#)
.unwrap()
});
/// Semver-like version struct
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct Version {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
}
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<Self> {
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<u32> =
c.name("minor").map(|minor| minor.as_str().parse().unwrap());
let patch: Option<u32> =
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<Ordering> {
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) }));
}
}

View File

@@ -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<Image> {
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::<Vec<JsonValue>>(),
}
}
// 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);
}
}

68
src/utils/json.rs Normal file
View File

@@ -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::<Vec<JsonValue>>(),
}
}

13
src/utils/link.rs Normal file
View File

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

32
src/utils/logging.rs Normal file
View File

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

8
src/utils/misc.rs Normal file
View File

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

7
src/utils/mod.rs Normal file
View File

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

46
src/utils/reference.rs Normal file
View File

@@ -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<Regex> = Lazy::new(|| {
Regex::new(
r#"^(?P<name>(?:(?P<registry>(?:(?:localhost|[\w-]+(?:\.[\w-]+)+)(?::\d+)?)|[\w]+:\d+)/)?(?P<repository>[a-z0-9_.-]+(?:/[a-z0-9_.-]+)*))(?::(?P<tag>[\w][\w.-]{0,127}))?$"#, // From https://regex101.com/r/nmSDPA/1
)
.unwrap()
});

55
src/utils/request.rs Normal file
View File

@@ -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<String> {
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)
}
}
}

View File

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

View File

@@ -29,8 +29,8 @@ function App() {
className={`bg-white shadow-sm dark:bg-${theme}-900 my-8 rounded-md`}
>
<dl className="grid grid-cols-1 gap-1 overflow-hidden *:relative md:grid-cols-2 lg:grid-cols-4">
{Object.entries(data.metrics).map(([name, value]) => (
<Statistic name={name} value={value} key={name} />
{Object.entries(data.metrics).map(([name]) => (
<Statistic name={name as keyof typeof data.metrics} metrics={data.metrics} key={name} />
))}
</dl>
</div>

View File

@@ -67,30 +67,7 @@ export default function Image({ data }: { data: Image }) {
<li className="break-all text-start">
<IconCube className="size-6 shrink-0" />
{data.reference}
{data.result.has_update == false && (
<WithTooltip
text="Up to date"
className="ml-auto size-6 shrink-0 text-green-500"
>
<IconCircleCheckFilled />
</WithTooltip>
)}
{data.result.has_update == true && (
<WithTooltip
text="Update available"
className="ml-auto size-6 shrink-0 text-blue-500"
>
<IconCircleArrowUpFilled />
</WithTooltip>
)}
{data.result.has_update == null && (
<WithTooltip
text="Unknown"
className="ml-auto size-6 shrink-0 text-gray-500"
>
<IconHelpCircleFilled />
</WithTooltip>
)}
<Icon data={data} />
</li>
</button>
<Dialog open={open} onClose={setOpen} className="relative z-10">
@@ -133,24 +110,7 @@ export default function Image({ data }: { data: Image }) {
</button>
</div>
<div className="flex items-center gap-3">
{data.result.has_update == false && (
<>
<IconCircleCheckFilled className="size-6 shrink-0 text-green-500" />
Up to date
</>
)}
{data.result.has_update == true && (
<>
<IconCircleArrowUpFilled className="size-6 shrink-0 text-blue-500" />
Update available
</>
)}
{data.result.has_update == null && (
<>
<IconHelpCircleFilled className="size-6 shrink-0 text-gray-500" />
Unknown
</>
)}
<DialogIcon data={data} />
</div>
<div className="mb-4 flex items-center gap-3">
<IconStopwatch className="size-6 shrink-0 text-gray-500" />
@@ -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`}
>
<p className="overflow-scroll">
docker pull {data.reference}
docker pull {data.result.info?.type == "version" ? data.reference.replace(data.parts.tag, data.result.info.new_version) : data.reference}
</p>
{navigator.clipboard &&
(copySuccess ? (
@@ -180,7 +140,7 @@ export default function Image({ data }: { data: Image }) {
<button
className="duration-50 absolute right-3 opacity-0 transition-opacity group-hover:opacity-100"
onClick={handleCopy(
`docker pull ${data.reference}`,
`docker pull ${data.result.info?.type == "version" ? data.reference.replace(data.parts.tag, data.result.info.new_version) : data.reference}`,
)}
>
<IconCopy />
@@ -190,25 +150,33 @@ export default function Image({ data }: { data: Image }) {
</div>
)}
<div className="flex flex-col gap-1">
{data.local_digests.length > 1 ? "Local digests" : "Local digest"}
{data.result.info?.type == "digest" && (
<>
{data.result.info.local_digests.length > 1
? "Local digests"
: "Local digest"}
<div
className={`bg-${theme}-100 dark:bg-${theme}-950 scrollable rounded-md px-3 py-2 font-mono text-gray-500`}
>
<p className="overflow-x-scroll">
{data.local_digests.join("\n")}
{data.result.info.local_digests.join("\n")}
</p>
</div>
</div>
{data.remote_digest && (
{data.result.info.remote_digest && (
<div className="flex flex-col gap-1">
Remote digest
<div
className={`bg-${theme}-100 dark:bg-${theme}-950 rounded-md px-3 py-2 font-mono text-gray-500`}
>
<p className="overflow-x-scroll">{data.remote_digest}</p>
<p className="overflow-x-scroll">
{data.result.info.remote_digest}
</p>
</div>
</div>
)}
</>
)}
</div>
</div>
</DialogPanel>
</div>
@@ -217,3 +185,119 @@ export default function Image({ data }: { data: Image }) {
</>
);
}
function Icon({ data }: { data: Image }) {
switch (data.result.has_update) {
case null:
return (
<WithTooltip
text="Unknown"
className="ml-auto size-6 shrink-0 text-gray-500"
>
<IconHelpCircleFilled />
</WithTooltip>
);
case false:
return (
<WithTooltip
text="Up to date"
className="ml-auto size-6 shrink-0 text-green-500"
>
<IconCircleCheckFilled />
</WithTooltip>
);
case true:
if (data.result.info?.type === "version") {
switch (data.result.info.version_update_type) {
case "major":
return (
<WithTooltip
text="Major Update"
className="ml-auto size-6 shrink-0 text-red-500"
>
<IconCircleArrowUpFilled />
</WithTooltip>
);
case "minor":
return (
<WithTooltip
text="Minor Update"
className="ml-auto size-6 shrink-0 text-yellow-500"
>
<IconCircleArrowUpFilled />
</WithTooltip>
);
case "patch":
return (
<WithTooltip
text="Patch Update"
className="ml-auto size-6 shrink-0 text-blue-500"
>
<IconCircleArrowUpFilled />
</WithTooltip>
);
}
} else if (data.result.info?.type === "digest") {
return (
<WithTooltip
text="Update available"
className="ml-auto size-6 shrink-0 text-blue-500"
>
<IconCircleArrowUpFilled />
</WithTooltip>
);
}
}
}
function DialogIcon({ data }: { data: Image }) {
switch (data.result.has_update) {
case null:
return (
<>
<IconHelpCircleFilled className="size-6 shrink-0 text-gray-500" />
Unknown
</>
);
case false:
return (
<>
<IconCircleCheckFilled className="size-6 shrink-0 text-green-500" />
Up to date
</>
);
case true:
if (data.result.info?.type === "version") {
switch (data.result.info.version_update_type) {
case "major":
return (
<>
<IconCircleArrowUpFilled className="size-6 shrink-0 text-red-500" />
Major update
</>
);
case "minor":
return (
<>
<IconCircleArrowUpFilled className="size-6 shrink-0 text-yellow-500" />
Minor update
</>
);
case "patch":
return (
<>
<IconCircleArrowUpFilled className="size-6 shrink-0 text-blue-500" />
Patch update
</>
);
}
} else if (data.result.info?.type === "digest") {
return (
<>
<IconCircleArrowUpFilled className="size-6 shrink-0 text-blue-500" />
Update available
</>
);
}
}
}

View File

@@ -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 (
<div
className={`before:bg-${theme}-200 before:dark:bg-${theme}-800 after:bg-${theme}-200 after:dark:bg-${theme}-800 gi`}
@@ -23,22 +32,20 @@ export default function Statistic({
<dt
className={`text-${theme}-500 dark:text-${theme}-400 font-medium leading-6`}
>
{name}
{displayName}
</dt>
<div className="flex items-center justify-between gap-1">
<dd className="w-full text-3xl font-medium leading-10 tracking-tight text-black dark:text-white">
{value}
{metrics[name]}
</dd>
{name == "Monitored images" && (
{name === "monitored_images" && (
<IconEyeFilled className="size-6 shrink-0 text-black dark:text-white" />
)}
{name == "Up to date" && (
{name === "up_to_date" && (
<IconCircleCheckFilled className="size-6 shrink-0 text-green-500" />
)}
{name == "Update available" && (
<IconCircleArrowUpFilled className="size-6 shrink-0 text-blue-500" />
)}
{name == "Unknown" && (
{name === "updates_available" && getUpdatesAvailableIcon(metrics)}
{name === "unknown" && (
<IconHelpCircleFilled className="size-6 shrink-0 text-gray-500" />
)}
</div>
@@ -46,3 +53,27 @@ export default function Statistic({
</div>
);
}
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 <IconCircleArrowUpFilled className={`size-6 shrink-0 ${color}`} />;
}

View File

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