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

Refactor (#32)

This commit is contained in:
Sergio
2024-10-11 21:22:39 +03:00
committed by GitHub
parent 5867cb375f
commit 88885aa1dd
19 changed files with 503 additions and 400 deletions

16
Cargo.lock generated
View File

@@ -339,7 +339,7 @@ dependencies = [
[[package]]
name = "cup"
version = "2.3.1"
version = "2.4.0"
dependencies = [
"bollard",
"chrono",
@@ -354,6 +354,7 @@ dependencies = [
"reqwest",
"reqwest-middleware",
"reqwest-retry",
"rustc-hash",
"termsize",
"tokio",
"xitca-web",
@@ -1460,18 +1461,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.204"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.204"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [
"proc-macro2",
"quote",
@@ -1480,11 +1481,12 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.120"
version = "1.0.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5"
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "cup"
version = "2.3.1"
version = "2.4.0"
edition = "2021"
[dependencies]
@@ -20,6 +20,7 @@ reqwest = { version = "0.12.7", default-features = false, features = ["rustls-tl
futures = "0.3.30"
reqwest-retry = "0.6.1"
reqwest-middleware = "0.3.3"
rustc-hash = "2.0.0"
[features]
default = ["server", "cli"]

View File

@@ -13,11 +13,11 @@ _If you like this project and/or use Cup, please consider starring the project
## Features
- Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my test machine, it took ~12 seconds for ~95 images.
- Extremely fast. Cup takes full advantage of your CPU and is hightly optimized, resulting in lightning fast speed. On my Raspberry Pi 5, it took 3.7 seconds for 58 images!
- Supports most registries, including Docker Hub, ghcr.io, Quay, lscr.io and even Gitea (or derivatives)
- Doesn't exhaust any rate limits. This is the original reason I created Cup. It was inspired by [What's up docker?](https://github.com/fmartinou/whats-up-docker) which would always use it up.
- Beautiful CLI and web interface for checking on your containers any time.
- The binary is tiny! At the time of writing it's just 5.1 MB. No more pulling 100+ MB docker images for a such a simple program.
- The binary is tiny! At the time of writing it's just 5.2 MB. No more pulling 100+ MB docker images for a such a simple program.
- JSON output for both the CLI and web interface so you can connect Cup to integrations. It's easy to parse and makes webhooks and pretty dashboards simple to set up!
## Documentation
@@ -26,11 +26,10 @@ Take a look at https://sergi0g.github.io/cup/docs!
## Limitations
Cup is a work in progress. It might not have as many features as What's up Docker. If one of these features is really important for you, please consider using another tool.
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 doesn't support registries which use repositories without slashes. This includes Azure. This problem may sound a bit weird, but it's due to the regex that's used at the moment. This will (hopefully) be fixed in the future.~~
- ~~Cup doesn't support private images. This is on the roadmap. Currently, it just returns unknown for those images.~~
- Cup cannot 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)
- 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
Take a sneak peek at what's coming up in future releases on the [roadmap](https://github.com/users/sergi0g/projects/2)!

View File

@@ -1,5 +1,5 @@
import { Steps, Callout, Card, Cards } from "nextra-theme-docs";
import { IconPaint, IconLockOpen, IconKey } from '@tabler/icons-react';
import { IconPaint, IconLockOpen, IconKey, IconPlug } from '@tabler/icons-react';
# Configuration
@@ -13,7 +13,8 @@ For example, if using Podman, you might do
$ cup -s /run/user/1000/podman/podman.sock check
```
This option will hopefully be moved to the configuration file soon.
This option is also available in the configuration file and it's best to put it there.
<Card icon={<IconPlug />} title="Custom Docker socket" href="/docs/configuration/socket" />
## Configuration file

View File

@@ -0,0 +1,10 @@
# Socket
If you need to specify a custom Docker socket (e.g. because you're using Podman), you can use the `socket` option. Here's an example:
```json
{
"socket": "/run/user/1000/podman/podman.sock"
// Other options
}
```

View File

@@ -20,12 +20,22 @@ rockylinux:9-minimal Up to date
rabbitmq:3.11.9-management Up to date
...
some/deleted:image Unknown
[38:5:86mINFO ✨ Checked 58 images in 3772ms
```
### Check for updates to a specific image
```
### Check for updates to specific images
```ansi
$ cup check node:latest
node:latest has an update available
node:latest Update available
[38:5:86mINFO ✨ Checked 1 images in 1310ms
```
```ansi
$ cup check node:latest
nextcloud:30 Update available
postgres:14 Update available
mysql:8.0 Up to date
[38:5:86mINFO ✨ Checked 3 images in 1769ms
```
## Enable icons
@@ -46,7 +56,7 @@ $ cup check -r
Here is how it would look in Typescript:
```ts
type CupData = {
interface CupData {
metrics: {
monitored_images: number,
up_to_date: number,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,106 +1,95 @@
use std::collections::{HashMap, HashSet};
use futures::future::join_all;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::{
debug,
docker::get_images_from_docker_daemon,
image::Image,
registry::{check_auth, get_latest_digests, get_token},
utils::{new_reqwest_client, unsplit_image, CliConfig},
config::Config, image::Image, registry::{check_auth, get_token}, utils::new_reqwest_client
};
#[cfg(feature = "cli")]
use crate::docker::get_image_from_docker_daemon;
#[cfg(feature = "cli")]
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> {
// So we can filter vecs for duplicates
fn unique(&mut self);
fn unique(&mut self) -> Vec<T>;
}
impl<T> Unique<T> for Vec<T>
where
T: Clone + Eq + std::hash::Hash,
{
fn unique(self: &mut Vec<T>) {
let mut seen: HashSet<T> = HashSet::new();
/// 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()
}
}
pub async fn get_all_updates(options: &CliConfig) -> Vec<(String, Option<bool>)> {
let local_images = get_images_from_docker_daemon(options).await;
let mut image_map: HashMap<String, Option<String>> = HashMap::with_capacity(local_images.len());
for image in &local_images {
let img = unsplit_image(image);
image_map.insert(img, image.digest.clone());
}
let mut registries: Vec<&String> = local_images.iter().map(|image| &image.registry).collect();
registries.unique();
let mut remote_images: Vec<Image> = Vec::with_capacity(local_images.len());
/// Returns a list of updates for all images passed in.
pub async fn get_updates(images: &[Image], config: &Config) -> Vec<(String, Option<bool>)> {
// 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();
// 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();
// 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);
}
// Retrieve an authentication token (if required) for each registry.
let mut tokens: FxHashMap<&String, Option<String>> = FxHashMap::default();
for registry in registries {
if options.verbose {
debug!("Checking images from registry {}", registry)
}
let images: Vec<&Image> = local_images
.iter()
.filter(|image| &image.registry == registry)
.collect();
let credentials = options.config["authentication"][registry]
.clone()
.take_string()
.or(None);
let mut latest_images = match check_auth(registry, options, &client).await {
let credentials = config.authentication.get(registry);
match check_auth(registry, config, &client).await {
Some(auth_url) => {
let token = get_token(images.clone(), &auth_url, &credentials, &client).await;
if options.verbose {
debug!("Using token {}", token);
}
get_latest_digests(images, Some(&token), options, &client).await
let token = get_token(
image_map.get(registry).unwrap(),
&auth_url,
&credentials,
&client,
)
.await;
tokens.insert(registry, Some(token));
}
None => get_latest_digests(images, None, options, &client).await,
};
remote_images.append(&mut latest_images);
}
if options.verbose {
debug!("Collecting results")
}
let mut result: Vec<(String, Option<bool>)> = Vec::new();
remote_images.iter().for_each(|image| {
let img = unsplit_image(image);
match &image.digest {
Some(d) => {
let r = d != image_map.get(&img).unwrap().as_ref().unwrap();
result.push((img, Some(r)))
None => {
tokens.insert(registry, None);
}
None => result.push((img, None)),
}
});
}
// Create a Vec to store futures so we can await them all at once.
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 future = get_latest_digest(image, token.as_ref(), config, &client);
handles.push(future);
}
// Await all the futures
let final_images = join_all(handles).await;
let mut result: Vec<(String, Option<bool>)> = Vec::with_capacity(images.len());
final_images
.iter()
.for_each(|image| match &image.remote_digest {
Some(digest) => {
let has_update = !image.local_digests.as_ref().unwrap().contains(digest);
result.push((image.reference.clone(), Some(has_update)))
}
None => result.push((image.reference.clone(), None)),
});
result
}
#[cfg(feature = "cli")]
pub async fn get_update(image: &str, options: &CliConfig) -> Option<bool> {
let local_image = get_image_from_docker_daemon(options.socket.clone(), image).await;
let credentials = options.config["authentication"][&local_image.registry]
.clone()
.take_string()
.or(None);
let client = new_reqwest_client();
let token = match check_auth(&local_image.registry, options, &client).await {
Some(auth_url) => get_token(vec![&local_image], &auth_url, &credentials, &client).await,
None => String::new(),
};
if options.verbose {
debug!("Using token {}", token);
};
let remote_image = match token.as_str() {
"" => get_latest_digest(&local_image, None, options, &client).await,
_ => get_latest_digest(&local_image, Some(&token), options, &client).await,
};
match &remote_image.digest {
Some(d) => Some(d != &local_image.digest.unwrap()),
None => None,
}
}

130
src/config.rs Normal file
View File

@@ -0,0 +1,130 @@
use std::path::PathBuf;
use rustc_hash::FxHashMap;
use crate::error;
const VALID_KEYS: [&str; 4] = ["authentication", "theme", "insecure_registries", "socket"];
#[derive(Clone)]
pub enum Theme {
Default,
Blue,
}
#[derive(Clone)]
pub struct Config {
pub authentication: FxHashMap<String, String>,
pub theme: Theme,
pub insecure_registries: Vec<String>,
pub socket: Option<String>,
}
impl Config {
/// A stupid new function that exists just so calling `load` doesn't require a self argument
#[allow(clippy::new_without_default)]
pub fn new() -> Self {
Self {
authentication: FxHashMap::default(),
theme: Theme::Default,
insecure_registries: Vec::with_capacity(0),
socket: None
}
}
/// Reads the config from the file path provided and returns the parsed result.
pub fn load(&self, path: Option<PathBuf>) -> Self {
let raw_config = match &path {
Some(path) => std::fs::read_to_string(path),
None => Ok(String::from("{}")), // Empty config
};
if raw_config.is_err() {
panic!(
"Failed to read config file from {}. Are you sure the file exists?",
&path.unwrap().to_str().unwrap()
)
};
self.parse(&raw_config.unwrap()) // We can safely unwrap here
}
/// Parses and validates the config. The process is quite manual and I would rather use a library, but I don't want to grow the dependency tree, for a config as simple as this one.
/// Many of these checks are stupid, but we either validate the config properly, or we don't at all, so... this is the result. I _am not_ proud of this code.
pub fn parse(&self, raw_config: &str) -> Self {
let json = match json::parse(raw_config) {
Ok(v) => v,
Err(e) => panic!("Failed to parse config!\n{}", e),
};
// In the code, raw_<key> means the JsonValue from the parsed config, before it's validated.
// Authentication
let raw_authentication = &json["authentication"];
if !raw_authentication.is_null() && !raw_authentication.is_object() {
error!("Config key `authentication` must be an object!");
}
let mut authentication: FxHashMap<String, String> = FxHashMap::default();
raw_authentication.entries().for_each(|(registry, key)| {
if !key.is_string() {
error!("Config key `authentication.{}` must be a string!", registry);
}
authentication.insert(registry.to_string(), key.to_string());
});
// Theme
let raw_theme = &json["theme"];
if !raw_theme.is_null() && !raw_theme.is_string() {
error!("Config key `theme` must be a string!");
}
let theme: Theme = {
if raw_theme.is_null() {
Theme::Default
} else {
match raw_theme.as_str().unwrap() {
"default" => Theme::Default,
"blue" => Theme::Blue,
_ => {
error!("Config key `theme` must be one of: `default`, `blue`!");
}
}
}
};
// Insecure registries
let raw_insecure_registries = &json["insecure_registries"];
if !raw_insecure_registries.is_null() && !raw_insecure_registries.is_array() {
error!("Config key `insecure_registries` must be an array!");
}
let insecure_registries: Vec<String> = raw_insecure_registries
.members()
.map(|registry| {
if !registry.is_string() {
error!("Config key `insecure_registries` must only consist of strings!");
} else {
registry.as_str().unwrap().to_owned()
}
})
.collect();
// Socket
let raw_socket = &json["socket"];
if !raw_socket.is_null() && !raw_socket.is_string() {
error!("Config key `socket` must be a string!");
}
let socket: Option<String> = if raw_socket.is_null() {
None
} else {
Some(raw_socket.to_string())
};
// Check for extra keys
json.entries().for_each(|(key, _)| {
if !VALID_KEYS.contains(&key) {
error!("Invalid key `{}`", key)
}
});
Self {
authentication,
theme,
insecure_registries,
socket,
}
}
}

View File

@@ -1,14 +1,8 @@
use bollard::{secret::ImageSummary, ClientVersion, Docker};
use bollard::{models::ImageInspect, ClientVersion, Docker};
#[cfg(feature = "cli")]
use bollard::secret::ImageInspect;
use futures::future::join_all;
use crate::{
error,
image::Image,
utils::{split_image, CliConfig},
};
use crate::{error, image::Image, config::Config};
fn create_docker_client(socket: Option<String>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {
@@ -29,47 +23,79 @@ fn create_docker_client(socket: Option<String>) -> Docker {
}
}
pub async fn get_images_from_docker_daemon(options: &CliConfig) -> Vec<Image> {
let client: Docker = create_docker_client(options.socket.clone());
let images: Vec<ImageSummary> = match client.list_images::<String>(None).await {
Ok(images) => images,
Err(e) => {
error!("Failed to retrieve list of images available!\n{}", e)
}
};
let mut handles = Vec::new();
for image in images {
handles.push(Image::from(image, options))
}
join_all(handles)
.await
.iter()
.filter(|img| img.is_some())
.map(|img| img.clone().unwrap())
.collect()
}
#[cfg(feature = "cli")]
pub async fn get_image_from_docker_daemon(socket: Option<String>, name: &str) -> Image {
let client: Docker = create_docker_client(socket);
let image: ImageInspect = match client.inspect_image(name).await {
Ok(i) => i,
Err(e) => error!("Failed to retrieve image {} from daemon\n{}", name, e),
};
match image.repo_tags {
Some(_) => (),
None => error!("Image has no tags"), // I think this is actually unreachable
}
match image.repo_digests {
Some(d) => {
let (registry, repository, tag) = split_image(&image.repo_tags.unwrap()[0]);
Image {
registry,
repository,
tag,
digest: Some(d[0].clone().split('@').collect::<Vec<&str>>()[1].to_string()),
/// Retrieves images from Docker daemon. If `references` is Some, return only the images whose references match the ones specified.
pub async fn get_images_from_docker_daemon(
config: &Config,
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.
// let mut filters = HashMap::with_capacity(1);
// match references {
// Some(refs) => {
// filters.insert("reference".to_string(), refs.clone());
// }
// None => (),
// }
// let images: Vec<ImageSummary> = match client
// .list_images::<String>(Some(ListImagesOptions {
// filters,
// ..Default::default()
// }))
// .await
// {
// Ok(images) => images,
// Err(e) => {
// error!("Failed to retrieve list of images available!\n{}", e)
// }
// };
// let mut handles = Vec::new();
// for image in images {
// handles.push(Image::from(image, options))
// }
// join_all(handles)
// .await
// .iter()
// .filter_map(|img| img.clone())
// .collect()
match references {
Some(refs) => {
let mut inspect_handles = Vec::with_capacity(refs.len());
for reference in refs {
inspect_handles.push(client.inspect_image(reference));
}
let inspects: Vec<ImageInspect> = join_all(inspect_handles)
.await
.iter()
.filter(|inspect| inspect.is_ok())
.map(|inspect| inspect.as_ref().unwrap().clone())
.collect();
let mut image_handles = Vec::with_capacity(inspects.len());
for inspect in inspects {
image_handles.push(Image::from_inspect(inspect.clone()));
}
join_all(image_handles)
.await
.iter()
.filter_map(|img| img.clone())
.collect()
}
None => {
let images = match client.list_images::<String>(None).await {
Ok(images) => images,
Err(e) => {
error!("Failed to retrieve list of images available!\n{}", e)
}
};
let mut handles = Vec::new();
for image in images {
handles.push(Image::from_summary(image))
}
join_all(handles)
.await
.iter()
.filter_map(|img| img.clone())
.collect()
}
None => error!("No digests found for image {}", name),
}
}

View File

@@ -1,7 +1,6 @@
use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use json::object;
use crate::utils::{sort_update_vec, to_json};
@@ -33,7 +32,7 @@ pub fn print_updates(updates: &[(String, Option<bool>)], icons: &bool) {
let dynamic_space =
" ".repeat(term_width - description.len() - icon.len() - update.0.len());
println!(
"{}{}{}{}{}",
"{}{}{}{}{}\u{001b}[0m",
color, icon, update.0, dynamic_space, description
);
}
@@ -43,30 +42,12 @@ pub fn print_raw_updates(updates: &[(String, Option<bool>)]) {
println!("{}", json::stringify(to_json(updates)));
}
pub fn print_update(name: &str, has_update: &Option<bool>) {
let color = match has_update {
Some(true) => "\u{001b}[38;5;12m",
Some(false) => "\u{001b}[38;5;2m",
None => "\u{001b}[38;5;8m",
};
let description = match has_update {
Some(true) => "has an update available",
Some(false) => "is up to date",
None => "wasn't found",
};
println!("{}{} {}", color, name, description);
}
pub fn print_raw_update(name: &str, has_update: &Option<bool>) {
let result = object! {images: {[name]: *has_update}};
println!("{}", result);
}
pub struct Spinner {
spinner: ProgressBar,
}
impl Spinner {
#[allow(clippy::new_without_default)]
pub fn new() -> Spinner {
let spinner = ProgressBar::new_spinner();
let style: &[&str] = &["", "", "", "", "", "", "", "", "", ""];

View File

@@ -1,41 +1,119 @@
use bollard::secret::ImageSummary;
use bollard::models::{ImageInspect, ImageSummary};
use once_cell::sync::Lazy;
use regex::Regex;
use crate::{
debug,
utils::{split_image, CliConfig},
};
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)]
pub struct Image {
pub registry: String,
pub repository: String,
pub tag: String,
pub digest: Option<String>,
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>,
}
impl Image {
pub async fn from(image: ImageSummary, options: &CliConfig) -> Option<Self> {
/// 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 (registry, repository, tag) = split_image(&image.repo_tags[0]);
let image = Image {
registry,
repository,
tag,
digest: Some(
image.repo_digests[0]
let mut image = Image {
reference: image.repo_tags[0].clone(),
registry: None,
repository: None,
tag: None,
local_digests: Some(
image
.repo_digests
.clone()
.split('@')
.collect::<Vec<&str>>()[1]
.to_string(),
.iter()
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
.collect(),
),
remote_digest: None,
};
let (registry, repository, tag) = image.split();
image.registry = Some(registry);
image.repository = Some(repository);
image.tag = Some(tag);
return Some(image);
} else if options.verbose {
debug!(
"Skipped an image\nTags: {:#?}\nDigests: {:#?}",
image.repo_tags, image.repo_digests
)
}
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(),
registry: None,
repository: None,
tag: None,
local_digests: Some(
image
.repo_digests
.unwrap()
.clone()
.iter()
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
.collect(),
),
remote_digest: None,
};
let (registry, repository, tag) = image.split();
image.registry = Some(registry);
image.repository = Some(repository);
image.tag = Some(tag);
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),
}
}
}
/// 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()
});

View File

@@ -1,15 +1,16 @@
#[cfg(feature = "cli")]
use check::{get_all_updates, get_update};
use check::get_updates;
use chrono::Local;
use clap::{Parser, Subcommand};
use config::Config;
use docker::get_images_from_docker_daemon;
#[cfg(feature = "cli")]
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
use formatting::{print_raw_updates, print_updates, Spinner};
#[cfg(feature = "server")]
use server::serve;
use std::path::PathBuf;
use utils::{load_config, CliConfig};
pub mod check;
pub mod config;
pub mod docker;
#[cfg(feature = "cli")]
pub mod formatting;
@@ -26,13 +27,6 @@ struct Cli {
socket: Option<String>,
#[arg(short, long, default_value_t = String::new(), help = "Config file path")]
config_path: String,
#[arg(
short,
long,
default_value_t = false,
help = "Enable verbose (debug) logging"
)]
verbose: bool,
#[command(subcommand)]
command: Option<Commands>,
}
@@ -41,8 +35,8 @@ struct Cli {
enum Commands {
#[cfg(feature = "cli")]
Check {
#[arg(default_value = None)]
image: Option<String>,
#[arg(name = "Images", default_value = None)]
references: Option<Vec<String>>,
#[arg(short, long, default_value_t = false, help = "Enable icons")]
icons: bool,
#[arg(
@@ -72,50 +66,38 @@ async fn main() {
"" => None,
path => Some(PathBuf::from(path)),
};
if cli.verbose {
debug!("CLI options:");
debug!("Config path: {:?}", cfg_path);
debug!("Socket: {:?}", &cli.socket)
}
let cli_config = CliConfig {
socket: cli.socket,
verbose: cli.verbose,
config: load_config(cfg_path),
};
if cli.verbose {
debug!("Config: {}", cli_config.config)
let mut config = Config::new().load(cfg_path);
match cli.socket {
Some(socket) => config.socket = Some(socket),
None => ()
}
match &cli.command {
#[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image {
Some(name) => {
let has_update = get_update(name, &cli_config).await;
match raw {
true => print_raw_update(name, &has_update),
false => print_update(name, &has_update),
};
}
None => {
let start = Local::now().timestamp_millis();
match raw {
true => {
let updates = get_all_updates(&cli_config).await;
print_raw_updates(&updates);
}
false => {
let spinner = Spinner::new();
let updates = get_all_updates(&cli_config).await;
spinner.succeed();
let end = Local::now().timestamp_millis();
print_updates(&updates, icons);
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
}
};
}
},
Some(Commands::Check {
references,
icons,
raw,
}) => {
let start = Local::now().timestamp_millis();
let images = get_images_from_docker_daemon(&config, references).await;
match raw {
true => {
let updates = get_updates(&images, &config).await;
print_raw_updates(&updates);
}
false => {
let spinner = Spinner::new();
let updates = get_updates(&images, &config).await;
spinner.succeed();
let end = Local::now().timestamp_millis();
print_updates(&updates, icons);
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
}
};
}
#[cfg(feature = "server")]
Some(Commands::Serve { port }) => {
let _ = serve(port, &cli_config).await;
let _ = serve(port, &config).await;
}
None => (),
}

View File

@@ -1,23 +1,16 @@
use futures::future::join_all;
use json::JsonValue;
use http_auth::parse_challenges;
use reqwest_middleware::ClientWithMiddleware;
use crate::{debug, error, image::Image, utils::CliConfig, warn};
use crate::{config::Config, error, image::Image, warn};
pub async fn check_auth(
registry: &str,
options: &CliConfig,
config: &Config,
client: &ClientWithMiddleware,
) -> Option<String> {
let protocol = if options.config["insecure_registries"].contains(registry) {
if options.verbose {
debug!(
"{} is configured as an insecure registry. Downgrading to HTTP",
registry
);
};
let protocol = if config.insecure_registries.contains(&registry.to_string()) {
"http"
} else {
"https"
@@ -62,11 +55,10 @@ pub async fn check_auth(
pub async fn get_latest_digest(
image: &Image,
token: Option<&String>,
options: &CliConfig,
config: &Config,
client: &ClientWithMiddleware,
) -> Image {
let protocol = if options.config["insecure_registries"]
.contains(json::JsonValue::from(image.registry.clone()))
let protocol = if config.insecure_registries.contains(&image.registry.clone().unwrap())
{
"http"
} else {
@@ -74,7 +66,10 @@ pub async fn get_latest_digest(
};
let mut request = client.head(format!(
"{}://{}/v2/{}/manifests/{}",
protocol, &image.registry, &image.repository, &image.tag
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));
@@ -87,14 +82,14 @@ pub async fn get_latest_digest(
let status = response.status();
if status == 401 {
if token.is_some() {
warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry, token.unwrap());
warn!("Failed to authenticate to registry {} with given token!\n{}", &image.registry.as_ref().unwrap(), token.unwrap());
} else {
warn!("Registry requires authentication");
}
return Image { digest: None, ..image.clone() }
return Image { remote_digest: None, ..image.clone() }
} else if status == 404 {
warn!("Image {:?} not found", &image);
return Image { digest: None, ..image.clone() }
return Image { remote_digest: None, ..image.clone() }
} else {
response
}
@@ -102,7 +97,7 @@ pub async fn get_latest_digest(
Err(e) => {
if e.is_connect() {
warn!("Connection to registry failed.");
return Image { digest: None, ..image.clone() }
return Image { remote_digest: None, ..image.clone() }
} else {
error!("Unexpected error: {}", e.to_string())
}
@@ -110,7 +105,7 @@ pub async fn get_latest_digest(
};
match raw_response.headers().get("docker-content-digest") {
Some(digest) => Image {
digest: Some(digest.to_str().unwrap().to_string()),
remote_digest: Some(digest.to_str().unwrap().to_string()),
..image.clone()
},
None => error!(
@@ -120,28 +115,19 @@ pub async fn get_latest_digest(
}
}
pub async fn get_latest_digests(
images: Vec<&Image>,
token: Option<&String>,
options: &CliConfig,
client: &ClientWithMiddleware,
) -> Vec<Image> {
let mut handles = Vec::new();
for image in images {
handles.push(get_latest_digest(image, token, options, client))
}
join_all(handles).await
}
pub async fn get_token(
images: Vec<&Image>,
images: &Vec<&Image>,
auth_url: &str,
credentials: &Option<String>,
credentials: &Option<&String>,
client: &ClientWithMiddleware,
) -> String {
let mut final_url = auth_url.to_owned();
for image in &images {
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository);
for image in images {
final_url = format!(
"{}&scope=repository:{}:pull",
final_url,
image.repository.as_ref().unwrap()
);
}
let mut base_request = client
.get(&final_url)

View File

@@ -14,9 +14,7 @@ use xitca_web::{
};
use crate::{
check::get_all_updates,
error, info,
utils::{sort_update_vec, to_json, CliConfig},
check::get_updates, config::{Config, Theme}, docker::get_images_from_docker_daemon, info, utils::{sort_update_vec, to_json}
};
const HTML: &str = include_str!("static/index.html");
@@ -26,9 +24,9 @@ const FAVICON_ICO: &[u8] = include_bytes!("static/favicon.ico");
const FAVICON_SVG: &[u8] = include_bytes!("static/favicon.svg");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("static/apple-touch-icon.png");
pub async fn serve(port: &u16, options: &CliConfig) -> std::io::Result<()> {
pub async fn serve(port: &u16, config: &Config) -> std::io::Result<()> {
info!("Starting server, please wait...");
let data = ServerData::new(options).await;
let data = ServerData::new(config).await;
info!("Ready to start!");
App::new()
.with_state(Arc::new(Mutex::new(data)))
@@ -94,14 +92,14 @@ struct ServerData {
template: String,
raw_updates: Vec<(String, Option<bool>)>,
json: JsonValue,
options: CliConfig,
config: Config,
theme: &'static str,
}
impl ServerData {
async fn new(options: &CliConfig) -> Self {
async fn new(config: &Config) -> Self {
let mut s = Self {
options: options.clone(),
config: config.clone(),
template: String::new(),
json: json::object! {
metrics: json::object! {},
@@ -118,7 +116,8 @@ impl ServerData {
if !self.raw_updates.is_empty() {
info!("Refreshing data");
}
let updates = sort_update_vec(&get_all_updates(&self.options).await);
let images = get_images_from_docker_daemon(&self.config, &None).await;
let updates = sort_update_vec(&get_updates(&images, &self.config).await);
let end = Local::now().timestamp_millis();
info!("✨ Checked {} images in {}ms", updates.len(), end - start);
self.raw_updates = updates;
@@ -141,16 +140,9 @@ impl ServerData {
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.to_string()
.into();
self.theme = match &self.options.config["theme"].as_str() {
Some(t) => match *t {
"default" => "neutral",
"blue" => "gray",
_ => error!(
"Invalid theme {} specified! Please choose between 'default' and 'blue'",
t
),
},
None => "neutral",
self.theme = match &self.config.theme {
Theme::Default => "neutral",
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()}],

View File

@@ -1,69 +1,7 @@
use std::path::PathBuf;
use crate::{error, image::Image};
use json::{object, JsonValue};
use once_cell::sync::Lazy;
use regex::Regex;
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{policies::ExponentialBackoff, RetryTransientMiddleware};
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()
});
/// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest'].
pub fn split_image(image: &str) -> (String, String, String) {
match RE.captures(image) {
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 {}", image),
},
match c.name("tag") {
Some(tag) => tag.as_str().to_owned(),
None => String::from("latest"),
},
);
}
None => error!("Failed to parse image {}", image),
}
}
/// Given an image's parts which were previously created by split_image, recreate a reference that docker would use. This means removing the registry part, if it's Docker Hub and removing "library" if the image is official
pub fn unsplit_image(image: &Image) -> String {
let reg = match image.registry.as_str() {
"registry-1.docker.io" => String::new(),
r => format!("{}/", r),
};
let repo = match image.repository.split('/').collect::<Vec<&str>>()[0] {
"library" => {
if reg.is_empty() {
image.repository.strip_prefix("library/").unwrap()
} else {
image.repository.as_str()
}
}
_ => image.repository.as_str(),
};
format!("{}{}:{}", reg, repo, image.tag)
}
/// Sorts the update vector alphabetically and where Some(true) > Some(false) > None
pub fn sort_update_vec(updates: &[(String, Option<bool>)]) -> Vec<(String, Option<bool>)> {
let mut sorted_updates = updates.to_vec();
@@ -82,24 +20,6 @@ pub fn sort_update_vec(updates: &[(String, Option<bool>)]) -> Vec<(String, Optio
sorted_updates.to_vec()
}
/// Tries to load the config from the path provided and perform basic validation
pub fn load_config(config_path: Option<PathBuf>) -> JsonValue {
let raw_config = match &config_path {
Some(path) => std::fs::read_to_string(path),
None => Ok(String::from("{\"theme\":\"default\"}")),
};
if raw_config.is_err() {
panic!(
"Failed to read config file from {}. Are you sure the file exists?",
&config_path.unwrap().to_str().unwrap()
)
};
match json::parse(&raw_config.unwrap()) {
Ok(v) => v,
Err(e) => panic!("Failed to parse config!\n{}", e),
}
}
pub fn to_json(updates: &[(String, Option<bool>)]) -> JsonValue {
let mut json_data: JsonValue = object! {
metrics: object! {},
@@ -124,21 +44,13 @@ pub fn to_json(updates: &[(String, Option<bool>)]) -> JsonValue {
json_data
}
/// Struct to hold some config values to avoid having to pass them all the time
#[derive(Clone)]
pub struct CliConfig {
pub socket: Option<String>,
pub verbose: bool,
pub config: 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[41m ERROR \x1b[0m {}", format!($($arg)*));
eprintln!("\x1b[38:5:204mERROR \x1b[0m {}", format!($($arg)*));
std::process::exit(1);
})
}
@@ -147,21 +59,21 @@ macro_rules! error {
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => ({
eprintln!("\x1b[103m WARN \x1b[0m {}", format!($($arg)*));
eprintln!("\x1b[38:5:192mWARN \x1b[0m {}", format!($($arg)*));
})
}
#[macro_export]
macro_rules! info {
($($arg:tt)*) => ({
println!("\x1b[44m INFO \x1b[0m {}", format!($($arg)*));
println!("\x1b[38:5:86mINFO \x1b[0m {}", format!($($arg)*));
})
}
#[macro_export]
macro_rules! debug {
($($arg:tt)*) => ({
println!("\x1b[48:5:57m DEBUG \x1b[0m {}", format!($($arg)*));
println!("\x1b[38:5:63mDEBUG \x1b[0m {}", format!($($arg)*));
})
}

View File

@@ -25,19 +25,19 @@ export default function Search({
return (
<div className={`w-full px-6 text-${theme}-500`}>
<div
className={`flex items-center w-full rounded-md border border-${theme}-200 dark:border-${theme}-700 px-2 gap-1 bg-${theme}-800 flex-nowrap peer`}
className={`flex items-center w-full rounded-md border border-${theme}-300 dark:border-${theme}-700 px-2 gap-1 bg-${theme}-200 dark:bg-${theme}-800 flex-nowrap peer`}
>
<IconSearch className="size-5" />
<div className="w-full">
<input
className={`w-full h-10 text-sm text-${theme}-400 focus:outline-none peer bg-transparent placeholder:text-${theme}-500`}
className={`w-full h-10 text-sm text-${theme}-600 dark:text-${theme}-400 focus:outline-none peer bg-transparent placeholder:text-${theme}-500`}
placeholder="Search"
onChange={handleChange}
value={searchQuery}
></input>
</div>
{showClear && (
<button onClick={handleClear} className={`hover:text-${theme}-400`}>
<button onClick={handleClear} className={`hover:text-${theme}-600 dark:hover:text-${theme}-400`}>
<IconX className="size-5" />
</button>
)}

View File

@@ -20,11 +20,15 @@ export default {
},
{
pattern: /bg-(gray|neutral)-800/,
variants: ["before:dark", "after:dark"],
variants: ["before:dark", "after:dark", "dark"],
},
{
pattern: /text-(gray|neutral)-600/,
variants: ["hover"]
},
{
pattern: /text-(gray|neutral)-400/,
variants: ["hover"]
variants: ["hover", "dark", "dark:hover"]
},
{
pattern: /text-(gray|neutral)-500/,
@@ -35,7 +39,7 @@ export default {
variants: ["dark"],
},
{
pattern: /border-(gray|neutral)-200/,
pattern: /border-(gray|neutral)-300/,
},
{
pattern: /border-(gray|neutral)-700/,