m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-17 01:23:39 -05:00

Added new full json API route and changed API routes

This commit is contained in:
Sergio
2024-10-25 12:12:59 +03:00
parent 8ab073d562
commit 8fd012efbe
9 changed files with 174 additions and 85 deletions

View File

@@ -2,7 +2,10 @@ use futures::future::join_all;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{
config::Config, image::Image, registry::{check_auth, get_token}, utils::new_reqwest_client
config::Config,
image::Image,
registry::{check_auth, get_token},
utils::new_reqwest_client,
};
use crate::registry::get_latest_digest;
@@ -78,7 +81,5 @@ pub async fn get_updates(images: &[Image], config: &Config) -> Vec<Image> {
handles.push(future);
}
// Await all the futures
let final_images = join_all(handles).await;
final_images
join_all(handles).await
}

View File

@@ -28,7 +28,7 @@ impl Config {
authentication: FxHashMap::default(),
theme: Theme::Default,
insecure_registries: Vec::with_capacity(0),
socket: None
socket: None,
}
}
/// Reads the config from the file path provided and returns the parsed result.

View File

@@ -2,7 +2,7 @@ use bollard::{models::ImageInspect, ClientVersion, Docker};
use futures::future::join_all;
use crate::{error, image::Image, config::Config};
use crate::{config::Config, error, image::Image};
fn create_docker_client(socket: Option<String>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {

View File

@@ -2,7 +2,10 @@ use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use crate::{image::{Image, Status}, utils::{sort_image_vec, to_simple_json}};
use crate::{
image::{Image, Status},
utils::{sort_image_vec, to_simple_json},
};
pub fn print_updates(updates: &[Image], icons: &bool) {
let sorted_images = sort_image_vec(updates);

View File

@@ -1,4 +1,7 @@
use std::fmt::Display;
use bollard::models::{ImageInspect, ImageSummary};
use json::{object, JsonValue};
use once_cell::sync::Lazy;
use regex::Regex;
@@ -6,7 +9,7 @@ use crate::error;
/// Image struct that contains all information that may be needed by a function.
/// It's designed to be passed around between functions
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Image {
pub reference: String,
pub registry: Option<String>,
@@ -14,7 +17,8 @@ pub struct Image {
pub tag: Option<String>,
pub local_digests: Option<Vec<String>>,
pub remote_digest: Option<String>,
pub error: Option<String>
pub error: Option<String>,
pub time_ms: i64
}
impl Image {
@@ -108,24 +112,38 @@ impl Image {
pub fn has_update(&self) -> Status {
if self.error.is_some() {
Status::Unknown(self.error.clone().unwrap())
} else if self.local_digests.as_ref().unwrap().contains(&self.remote_digest.as_ref().unwrap()) {
} else if self
.local_digests
.as_ref()
.unwrap()
.contains(self.remote_digest.as_ref().unwrap())
{
Status::UpToDate
} else {
Status::UpdateAvailable
}
}
}
impl Default for Image {
fn default() -> Self {
Self {
reference: String::new(),
registry: None,
repository: None,
tag: None,
local_digests: None,
remote_digest: None,
error: None
/// Converts image data into a `JsonValue`
pub fn to_json(&self) -> JsonValue {
let has_update = self.has_update();
object! {
reference: self.reference.clone(),
parts: object! {
registry: self.registry.clone(),
repository: self.repository.clone(),
tag: self.tag.clone()
},
local_digests: self.local_digests.clone(),
remote_digest: self.remote_digest.clone(),
result: object! { // API here will have to change for semver
has_update: has_update.to_option_bool(),
error: match has_update {
Status::Unknown(e) => Some(e),
_ => None
}
},
time: self.time_ms
}
}
}
@@ -142,16 +160,16 @@ static RE: Lazy<Regex> = Lazy::new(|| {
pub enum Status {
UpToDate,
UpdateAvailable,
Unknown(String)
Unknown(String),
}
impl ToString for Status {
fn to_string(&self) -> String {
match &self {
impl Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match &self {
Self::UpToDate => "Up to date",
Self::UpdateAvailable => "Update available",
Self::Unknown(_) => "Unknown"
}.to_string()
Self::Unknown(_) => "Unknown",
})
}
}
@@ -161,7 +179,7 @@ impl Status {
match &self {
Self::UpdateAvailable => Some(true),
Self::UpToDate => Some(false),
Self::Unknown(_) => None
Self::Unknown(_) => None,
}
}
}
}

View File

@@ -1,5 +1,4 @@
use check::get_updates;
use chrono::Local;
use clap::{Parser, Subcommand};
use config::Config;
use docker::get_images_from_docker_daemon;
@@ -7,6 +6,7 @@ use docker::get_images_from_docker_daemon;
use formatting::{print_raw_updates, print_updates, Spinner};
#[cfg(feature = "server")]
use server::serve;
use utils::timestamp;
use std::path::PathBuf;
pub mod check;
@@ -67,9 +67,8 @@ async fn main() {
path => Some(PathBuf::from(path)),
};
let mut config = Config::new().load(cfg_path);
match cli.socket {
Some(socket) => config.socket = Some(socket),
None => ()
if let Some(socket) = cli.socket {
config.socket = Some(socket)
}
match &cli.command {
#[cfg(feature = "cli")]
@@ -78,7 +77,7 @@ async fn main() {
icons,
raw,
}) => {
let start = Local::now().timestamp_millis();
let start = timestamp();
let images = get_images_from_docker_daemon(&config, references).await;
match raw {
true => {
@@ -89,7 +88,7 @@ async fn main() {
let spinner = Spinner::new();
let updates = get_updates(&images, &config).await;
spinner.succeed();
let end = Local::now().timestamp_millis();
let end = timestamp();
print_updates(&updates, icons);
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
}

View File

@@ -3,7 +3,7 @@ use json::JsonValue;
use http_auth::parse_challenges;
use reqwest_middleware::ClientWithMiddleware;
use crate::{config::Config, error, image::Image, warn};
use crate::{config::Config, error, image::Image, utils::timestamp, warn};
pub async fn check_auth(
registry: &str,
@@ -58,7 +58,10 @@ pub async fn get_latest_digest(
config: &Config,
client: &ClientWithMiddleware,
) -> Image {
let protocol = if config.insecure_registries.contains(&image.registry.clone().unwrap())
let start = timestamp();
let protocol = if config
.insecure_registries
.contains(&image.registry.clone().unwrap())
{
"http"
} else {
@@ -83,14 +86,14 @@ pub async fn get_latest_digest(
if status == 401 {
if token.is_some() {
warn!("Failed to authenticate to registry {} with token provided!\n{}", &image.registry.as_ref().unwrap(), token.unwrap());
return Image { remote_digest: None, error: Some(format!("Authentication token \"{}\" was not accepted", token.unwrap())), ..image.clone() }
return Image { remote_digest: None, error: Some(format!("Authentication token \"{}\" was not accepted", token.unwrap())), time_ms: timestamp() - start, ..image.clone() }
} else {
warn!("Registry requires authentication");
return Image { remote_digest: None, error: Some("Registry requires authentication".to_string()), ..image.clone() }
return Image { remote_digest: None, error: Some("Registry requires authentication".to_string()), time_ms: timestamp() - start, ..image.clone() }
}
} else if status == 404 {
warn!("Image {:?} not found", &image);
return Image { remote_digest: None, error: Some("Image not found".to_string()), ..image.clone() }
return Image { remote_digest: None, error: Some("Image not found".to_string()), time_ms: timestamp() - start, ..image.clone() }
} else {
response
}
@@ -98,7 +101,7 @@ pub async fn get_latest_digest(
Err(e) => {
if e.is_connect() {
warn!("Connection to registry failed.");
return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), ..image.clone() }
return Image { remote_digest: None, error: Some("Connection to registry failed".to_string()), time_ms: timestamp() - start, ..image.clone() }
} else {
error!("Unexpected error: {}", e.to_string())
}
@@ -107,6 +110,7 @@ pub async fn get_latest_digest(
match raw_response.headers().get("docker-content-digest") {
Some(digest) => Image {
remote_digest: Some(digest.to_str().unwrap().to_string()),
time_ms: timestamp() - start,
..image.clone()
},
None => error!(

View File

@@ -14,7 +14,12 @@ use xitca_web::{
};
use crate::{
check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, image::Image, info, utils::{sort_image_vec, to_simple_json}
check::get_updates,
config::{Config, Theme},
docker::get_images_from_docker_daemon,
image::Image,
info,
utils::{sort_image_vec, timestamp, to_full_json, to_simple_json},
};
const HTML: &str = include_str!("static/index.html");
@@ -31,7 +36,8 @@ pub async fn serve(port: &u16, config: &Config) -> std::io::Result<()> {
App::new()
.with_state(Arc::new(Mutex::new(data)))
.at("/", get(handler_service(_static)))
.at("/json", get(handler_service(json)))
.at("/api/v1/simple", get(handler_service(api_simple)))
.at("/api/v1/full", get(handler_service(api_full)))
.at("/refresh", get(handler_service(refresh)))
.at("/*", get(handler_service(_static)))
.enclosed(Logger::new())
@@ -77,9 +83,15 @@ async fn _static(data: StateRef<'_, Arc<Mutex<ServerData>>>, path: PathRef<'_>)
}
}
async fn json(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
async fn api_simple(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::new(ResponseBody::from(json::stringify(
data.lock().await.json.clone(),
data.lock().await.simple_json.clone(),
)))
}
async fn api_full(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
WebResponse::new(ResponseBody::from(json::stringify(
data.lock().await.full_json.clone(),
)))
}
@@ -91,7 +103,8 @@ async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
struct ServerData {
template: String,
raw_updates: Vec<Image>,
json: JsonValue,
simple_json: JsonValue,
full_json: JsonValue,
config: Config,
theme: &'static str,
}
@@ -101,10 +114,8 @@ impl ServerData {
let mut s = Self {
config: config.clone(),
template: String::new(),
json: json::object! {
metrics: json::object! {},
images: json::object! {},
},
simple_json: JsonValue::Null,
full_json: JsonValue::Null,
raw_updates: Vec::new(),
theme: "neutral",
};
@@ -112,13 +123,13 @@ impl ServerData {
s
}
async fn refresh(&mut self) {
let start = Local::now().timestamp_millis();
let start = timestamp();
if !self.raw_updates.is_empty() {
info!("Refreshing data");
}
let images = get_images_from_docker_daemon(&self.config, &None).await;
let updates = sort_image_vec(&get_updates(&images, &self.config).await);
let end = Local::now().timestamp_millis();
let end = timestamp();
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
self.raw_updates = updates;
let template = liquid::ParserBuilder::with_stdlib()
@@ -131,18 +142,23 @@ impl ServerData {
.iter()
.map(|image| object!({"name": image.reference, "has_update": image.has_update().to_option_bool().to_value()}),)
.collect::<Vec<Object>>();
self.json = to_simple_json(&self.raw_updates);
self.simple_json = to_simple_json(&self.raw_updates);
self.full_json = to_full_json(&self.raw_updates);
let last_updated = Local::now();
self.json["last_updated"] = last_updated
self.simple_json["last_updated"] = last_updated
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
self.full_json["last_updated"] = last_updated
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
self.theme = match &self.config.theme {
Theme::Default => "neutral",
Theme::Blue => "gray"
Theme::Blue => "gray",
};
let globals = object!({
"metrics": [{"name": "Monitored images", "value": self.json["metrics"]["monitored_images"].as_usize()}, {"name": "Up to date", "value": self.json["metrics"]["up_to_date"].as_usize()}, {"name": "Updates available", "value": self.json["metrics"]["update_available"].as_usize()}, {"name": "Unknown", "value": self.json["metrics"]["unknown"].as_usize()}],
"metrics": [{"name": "Monitored images", "value": self.simple_json["metrics"]["monitored_images"].as_usize()}, {"name": "Up to date", "value": self.simple_json["metrics"]["up_to_date"].as_usize()}, {"name": "Updates available", "value": self.simple_json["metrics"]["update_available"].as_usize()}, {"name": "Unknown", "value": self.simple_json["metrics"]["unknown"].as_usize()}],
"images": images,
"last_updated": last_updated.format("%Y-%m-%d %H:%M:%S").to_string(),
"theme": &self.theme

View File

@@ -1,3 +1,4 @@
use chrono::Local;
use json::{object, JsonValue};
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
@@ -8,29 +9,23 @@ use crate::image::{Image, Status};
pub fn sort_image_vec(updates: &[Image]) -> Vec<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::UpdateAvailable) => a.reference.cmp(&b.reference),
(Status::UpdateAvailable, Status::UpToDate | Status::Unknown(_)) => {
std::cmp::Ordering::Less
}
(Status::UpdateAvailable, Status::UpToDate | Status::Unknown(_)) => std::cmp::Ordering::Less,
(Status::UpToDate, Status::UpdateAvailable) => std::cmp::Ordering::Greater,
(Status::UpToDate, Status::UpToDate) => {
a.reference.cmp(&b.reference)
},
(Status::UpToDate, Status::UpToDate) => a.reference.cmp(&b.reference),
(Status::UpToDate, Status::Unknown(_)) => std::cmp::Ordering::Less,
(Status::Unknown(_), Status::UpdateAvailable | Status::UpToDate) => std::cmp::Ordering::Greater,
(Status::Unknown(_), Status::Unknown(_)) => {
a.reference.cmp(&b.reference)
(Status::Unknown(_), Status::UpdateAvailable | Status::UpToDate) => {
std::cmp::Ordering::Greater
}
(Status::Unknown(_), Status::Unknown(_)) => a.reference.cmp(&b.reference),
});
sorted_updates.to_vec()
}
/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. The output doesn't contain much detail
pub fn to_simple_json(updates: &[Image]) -> JsonValue {
let mut json_data: JsonValue = object! {
metrics: object! {},
images: object! {}
};
/// Helper function to get metrics used in JSON output
pub fn get_metrics(updates: &[Image]) -> JsonValue {
let mut up_to_date = 0;
let mut update_available = 0;
let mut unknown = 0;
@@ -39,23 +34,43 @@ pub fn to_simple_json(updates: &[Image]) -> JsonValue {
match has_update {
Status::UpdateAvailable => {
update_available += 1;
},
}
Status::UpToDate => {
up_to_date += 1;
},
}
Status::Unknown(_) => {
unknown += 1;
}
};
let _ = json_data["images"].insert(&image.reference, has_update.to_option_bool());
});
let _ = json_data["metrics"].insert("monitored_images", updates.len());
let _ = json_data["metrics"].insert("up_to_date", up_to_date);
let _ = json_data["metrics"].insert("update_available", update_available);
let _ = json_data["metrics"].insert("unknown", unknown);
object! {
monitored_images: updates.len(),
up_to_date: up_to_date,
update_available: update_available,
unknown: unknown
}
}
/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. The output doesn't contain much detail
pub fn to_simple_json(updates: &[Image]) -> JsonValue {
let mut json_data: JsonValue = object! {
metrics: get_metrics(updates),
images: object! {}
};
updates.iter().for_each(|image| {
let _ = json_data["images"].insert(&image.reference, image.has_update().to_option_bool());
});
json_data
}
/// Takes a slice of `Image` objects and returns a `JsonValue` of update info. All image data is included, useful for debugging.
pub fn to_full_json(updates: &[Image]) -> JsonValue {
object! {
metrics: get_metrics(updates),
images: updates.iter().map(|image| image.to_json()).collect::<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)
@@ -89,6 +104,7 @@ macro_rules! debug {
})
}
/// Creates a new reqwest client with automatic retries
pub fn new_reqwest_client() -> ClientWithMiddleware {
ClientBuilder::new(reqwest::Client::new())
.with(RetryTransientMiddleware::new_with_policy(
@@ -97,6 +113,10 @@ pub fn new_reqwest_client() -> ClientWithMiddleware {
.build()
}
pub fn timestamp() -> i64 {
Local::now().timestamp_millis()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -107,25 +127,39 @@ mod tests {
// Create test objects
let update_available_1 = Image {
reference: "busybox".to_string(),
local_digests: Some(vec!["some_digest".to_string(), "some_other_digest".to_string()]),
local_digests: Some(vec![
"some_digest".to_string(),
"some_other_digest".to_string(),
]),
remote_digest: Some("latest_digest".to_string()),
..Default::default()
};
let update_available_2 = Image {
reference: "library/alpine".to_string(),
local_digests: Some(vec!["some_digest".to_string(), "some_other_digest".to_string()]), // We don't need to mock real data, as this is a generic function
local_digests: Some(vec![
"some_digest".to_string(),
"some_other_digest".to_string(),
]), // We don't need to mock real data, as this is a generic function
remote_digest: Some("latest_digest".to_string()),
..Default::default()
};
let up_to_date_1 = Image {
reference: "docker:dind".to_string(),
local_digests: Some(vec!["some_digest".to_string(), "some_other_digest".to_string(), "latest_digest".to_string()]),
local_digests: Some(vec![
"some_digest".to_string(),
"some_other_digest".to_string(),
"latest_digest".to_string(),
]),
remote_digest: Some("latest_digest".to_string()),
..Default::default()
};
let up_to_date_2 = Image {
reference: "ghcr.io/sergi0g/cup".to_string(),
local_digests: Some(vec!["some_digest".to_string(), "some_other_digest".to_string(), "latest_digest".to_string()]),
local_digests: Some(vec![
"some_digest".to_string(),
"some_other_digest".to_string(),
"latest_digest".to_string(),
]),
remote_digest: Some("latest_digest".to_string()),
..Default::default()
};
@@ -139,8 +173,22 @@ mod tests {
error: Some("whoops".to_string()),
..Default::default()
};
let input_vec = vec![unknown_2.clone(), up_to_date_1.clone(), unknown_1.clone(), update_available_2.clone(), update_available_1.clone(), up_to_date_2.clone()];
let expected_vec = vec![update_available_1, update_available_2, up_to_date_1, up_to_date_2, unknown_1, unknown_2];
let input_vec = vec![
unknown_2.clone(),
up_to_date_1.clone(),
unknown_1.clone(),
update_available_2.clone(),
update_available_1.clone(),
up_to_date_2.clone(),
];
let expected_vec = vec![
update_available_1,
update_available_2,
up_to_date_1,
up_to_date_2,
unknown_1,
unknown_2,
];
// Sort the vec
let sorted_vec = sort_image_vec(&input_vec);
@@ -148,4 +196,4 @@ mod tests {
// Check results
assert_eq!(sorted_vec, expected_vec);
}
}
}