m/cup
1
0
mirror of https://github.com/sergi0g/cup.git synced 2025-11-17 09:33:38 -05:00

Complete rewrite

This commit is contained in:
Sergio
2024-07-08 16:57:00 +03:00
commit a3068324ee
19 changed files with 3653 additions and 0 deletions

84
src/docker.rs Normal file
View File

@@ -0,0 +1,84 @@
use bollard::{
secret::ImageSummary,
ClientVersion, Docker,
};
#[cfg(feature = "cli")]
use bollard::secret::ImageInspect;
use crate::{error, image::Image, utils::split_image};
fn create_docker_client(socket: Option<String>) -> Docker {
let client: Result<Docker, bollard::errors::Error> = match socket {
Some(sock) => Docker::connect_with_local(
&sock,
120,
&ClientVersion {
major_version: 1,
minor_version: 44,
},
),
None => Docker::connect_with_local_defaults(),
};
match client {
Ok(d) => d,
Err(e) => error!("Failed to connect to docker socket!\n{}", e),
}
}
pub async fn get_images_from_docker_daemon(socket: Option<String>) -> Vec<Image> {
let client: Docker = create_docker_client(socket);
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 result: Vec<Image> = Vec::new();
for image in images {
if !image.repo_tags.is_empty() && image.repo_digests.len() == 1 {
for t in &image.repo_tags {
let (registry, repository, tag) = split_image(t);
result.push(Image {
registry,
repository,
tag,
digest: Some(
image.repo_digests[0]
.clone()
.split('@')
.collect::<Vec<&str>>()[1]
.to_string(),
),
});
}
}
}
result
}
#[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()),
}
}
None => error!("No digests found for image {}", name),
}
}

94
src/formatting.rs Normal file
View File

@@ -0,0 +1,94 @@
use std::time::Duration;
use indicatif::{ProgressBar, ProgressStyle};
use json::object;
use crate::utils::sort_update_vec;
pub fn print_updates(updates: &[(String, Option<bool>)], icons: &bool) {
let sorted_updates = sort_update_vec(updates);
let term_width: usize = termsize::get()
.unwrap_or(termsize::Size { rows: 24, cols: 80 })
.cols as usize;
for update in sorted_updates {
let description = match update.1 {
Some(true) => "Update available",
Some(false) => "Up to date",
None => "Unknown",
};
let icon = if *icons {
match update.1 {
Some(true) => "\u{f0aa} ",
Some(false) => "\u{f058} ",
None => "\u{f059} ",
}
} else {
""
};
let color = match update.1 {
Some(true) => "\u{001b}[38;5;12m",
Some(false) => "\u{001b}[38;5;2m",
None => "\u{001b}[38;5;8m",
};
let dynamic_space =
" ".repeat(term_width - description.len() - icon.len() - update.0.len());
println!(
"{}{}{}{}{}",
color, icon, update.0, dynamic_space, description
);
}
}
pub fn print_raw_updates(updates: &[(String, Option<bool>)]) {
let mut result = json::Array::new();
for update in updates {
result.push(object! {image: update.0.clone(), has_update: update.1});
}
println!("{}", json::stringify(result));
}
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!{image: name, has_update: *has_update};
println!("{}", json::stringify(result));
}
pub struct Spinner {
spinner: ProgressBar,
}
impl Spinner {
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);
}
}

7
src/image.rs Normal file
View File

@@ -0,0 +1,7 @@
#[derive(Clone, Debug)]
pub struct Image {
pub registry: String,
pub repository: String,
pub tag: String,
pub digest: Option<String>,
}

1
src/index.css Normal file

File diff suppressed because one or more lines are too long

163
src/main.rs Normal file
View File

@@ -0,0 +1,163 @@
use clap::{Parser, Subcommand};
#[cfg(feature = "cli")]
use docker::get_image_from_docker_daemon;
use docker::get_images_from_docker_daemon;
#[cfg(feature = "cli")]
use formatting::{print_raw_update, print_raw_updates, print_update, print_updates, Spinner};
use image::Image;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
#[cfg(feature = "cli")]
use registry::get_latest_digest;
use registry::{check_auth, get_latest_digests, get_token};
#[cfg(feature = "server")]
use server::serve;
use std::{
collections::{HashMap, HashSet},
sync::Mutex,
};
use utils::unsplit_image;
pub mod docker;
#[cfg(feature = "cli")]
pub mod formatting;
pub mod image;
pub mod registry;
#[cfg(feature = "server")]
pub mod server;
pub mod utils;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[arg(short, long, default_value = None)]
socket: Option<String>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
#[cfg(feature = "cli")]
Check {
#[arg(default_value = None)]
image: Option<String>,
#[arg(short, long, default_value_t = false, help = "Enable icons")]
icons: bool,
#[arg(short, long, default_value_t = false, help = "Output JSON instead of formatted text")]
raw: bool,
},
#[cfg(feature = "server")]
Serve {
#[arg(short, long, default_value_t = 8000, help = "Use a different port for the server")]
port: u16,
},
}
pub trait Unique<T> {
// So we can filter vecs for duplicates
fn unique(&mut self);
}
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();
self.retain(|item| seen.insert(item.clone()));
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
match &cli.command {
#[cfg(feature = "cli")]
Some(Commands::Check { image, icons, raw }) => match image {
Some(name) => {
let has_update = get_update(name, cli.socket).await;
match raw {
true => print_raw_update(name, &has_update),
false => print_update(name, &has_update),
};
}
None => {
match raw {
true => print_raw_updates(&get_all_updates(cli.socket).await),
false => {
let spinner = Spinner::new();
let updates = get_all_updates(cli.socket).await;
spinner.succeed();
print_updates(&updates, icons);
}
};
}
},
#[cfg(feature = "server")]
Some(Commands::Serve { port }) => {
let updates = get_all_updates(cli.socket).await;
let _ = serve(port, &updates).await;
}
None => (),
}
}
async fn get_all_updates(socket: Option<String>) -> Vec<(String, Option<bool>)> {
let image_map_mutex: Mutex<HashMap<String, &Option<String>>> = Mutex::new(HashMap::new());
let local_images = get_images_from_docker_daemon(socket).await;
local_images.par_iter().for_each(|image| {
let img = unsplit_image(&image.registry, &image.repository, &image.tag);
image_map_mutex.lock().unwrap().insert(img, &image.digest);
});
let image_map = image_map_mutex.lock().unwrap().clone();
let mut registries: Vec<&String> = local_images
.par_iter()
.map(|image| &image.registry)
.collect();
registries.unique();
let mut remote_images: Vec<Image> = Vec::new();
for registry in registries {
let images: Vec<&Image> = local_images
.par_iter()
.filter(|image| &image.registry == registry)
.collect();
let mut latest_images = match check_auth(registry) {
Some(auth_url) => {
let token = get_token(images.clone(), &auth_url);
get_latest_digests(images, Some(&token))
}
None => get_latest_digests(images, None),
};
remote_images.append(&mut latest_images);
}
let result_mutex: Mutex<Vec<(String, Option<bool>)>> = Mutex::new(Vec::new());
remote_images.par_iter().for_each(|image| {
let img = unsplit_image(&image.registry, &image.repository, &image.tag);
match &image.digest {
Some(d) => {
let r = d != image_map.get(&img).unwrap().as_ref().unwrap();
result_mutex.lock().unwrap().push((img, Some(r)))
}
None => result_mutex.lock().unwrap().push((img, None)),
}
});
let result = result_mutex.lock().unwrap().clone();
result
}
#[cfg(feature = "cli")]
async fn get_update(image: &str, socket: Option<String>) -> Option<bool> {
let local_image = get_image_from_docker_daemon(socket, image).await;
let token = match check_auth(&local_image.registry) {
Some(auth_url) => get_token(vec![&local_image], &auth_url),
None => String::new(),
};
let remote_image = match token.as_str() {
"" => get_latest_digest(&local_image, None),
_ => get_latest_digest(&local_image, Some(&token)),
};
match &remote_image.digest {
Some(d) => Some(d != &local_image.digest.unwrap()),
None => None,
}
}

122
src/registry.rs Normal file
View File

@@ -0,0 +1,122 @@
use std::sync::Mutex;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use ureq::Error;
use http_auth::parse_challenges;
use crate::{error, image::Image};
pub fn check_auth(registry: &str) -> Option<String> {
let response = ureq::get(&format!("https://{}/v2/", registry)).call();
match response {
Ok(_) => None,
Err(Error::Status(401, response)) => match response.header("www-authenticate") {
Some(challenge) => Some(parse_www_authenticate(challenge)),
None => error!("Server returned invalid response!"),
},
Err(e) => error!("{}", e),
}
}
pub fn get_latest_digest(image: &Image, token: Option<&String>) -> Image {
let mut request = ureq::head(&format!(
"https://{}/v2/{}/manifests/{}",
&image.registry, &image.repository, &image.tag
));
if let Some(t) = token {
request = request.set("Authorization", &format!("Bearer {}", t));
}
let raw_response = match request
.set("Accept", "application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json")
.call()
{
Ok(response) => response,
Err(Error::Status(401, response)) => {
if token.is_some() {
error!("Failed to authenticate with given token!\n{}", token.unwrap())
} else {
return get_latest_digest(
image,
Some(&get_token(
vec![image],
&parse_www_authenticate(response.header("www-authenticate").unwrap()),
)),
);
}
}
Err(Error::Status(_, _)) => {
return Image {
digest: None,
..image.clone()
}
}
Err(ureq::Error::Transport(e)) => error!("Failed to send request!\n{}", e),
};
match raw_response.header("docker-content-digest") {
Some(digest) => Image {
digest: Some(digest.to_string()),
..image.clone()
},
None => error!("Server returned invalid response! No docker-content-digest!"),
}
}
pub fn get_latest_digests(images: Vec<&Image>, token: Option<&String>) -> Vec<Image> {
let result: Mutex<Vec<Image>> = Mutex::new(Vec::new());
images.par_iter().for_each(|&image| {
let digest = get_latest_digest(image, token).digest;
result.lock().unwrap().push(Image {
digest,
..image.clone()
});
});
let r = result.lock().unwrap().clone();
r
}
pub fn get_token(images: Vec<&Image>, auth_url: &str) -> String {
let mut final_url = auth_url.to_owned();
for image in images {
final_url = format!("{}&scope=repository:{}:pull", final_url, image.repository);
}
let raw_response = match ureq::get(&final_url)
.set("Accept", "application/vnd.oci.image.index.v1+json")
.call()
{
Ok(response) => match response.into_string() {
Ok(res) => res,
Err(e) => {
error!("Failed to parse response into string!\n{}", e)
}
},
Err(e) => {
error!("Token request failed!\n{}", e)
}
};
let parsed_token_response = match json::parse(&raw_response) {
Ok(parsed) => parsed,
Err(e) => {
error!("Failed to parse server response\n{}", e)
}
};
parsed_token_response["token"].to_string()
}
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");
}
}

90
src/server.rs Normal file
View File

@@ -0,0 +1,90 @@
use std::sync::Mutex;
use liquid::{object, Object};
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use xitca_web::{
body::ResponseBody,
handler::{handler_service, path::PathOwn, state::StateOwn},
http::{Method, WebResponse},
route::get,
App,
};
const RAW_TEMPLATE: &str = include_str!("template.liquid");
const STYLE: &str = include_str!("index.css");
pub async fn serve(port: &u16, updates: &[(String, Option<bool>)]) -> std::io::Result<()> {
println!("Serving on http://0.0.0.0:{}", port);
App::new()
.with_state(updates.to_owned())
.at("/", get(handler_service(home)))
.at("/json", get(handler_service(json)))
.serve()
.bind(format!("0.0.0.0:{}", port))?
.run()
.wait()
}
async fn home(
updates: StateOwn<Vec<(String, Option<bool>)>>,
method: Method,
path: PathOwn,
) -> WebResponse {
let template = liquid::ParserBuilder::with_stdlib()
.build()
.unwrap()
.parse(RAW_TEMPLATE)
.unwrap();
let images = updates
.0
.par_iter()
.map(|(name, image)| match image {
Some(value) => {
if *value {
object!({"name": name, "status": "update-available"})
} else {
object!({"name": name, "status": "up-to-date"})
}
}
None => object!({"name": name, "status": "unknown"}),
})
.collect::<Vec<Object>>();
let uptodate = images
.par_iter()
.filter(|&o| o["status"] == "up-to-date")
.collect::<Vec<&Object>>()
.len();
let updatable = images
.par_iter()
.filter(|&o| o["status"] == "update-available")
.collect::<Vec<&Object>>()
.len();
let unknown = images
.par_iter()
.filter(|&o| o["status"] == "unknown")
.collect::<Vec<&Object>>()
.len();
let globals = object!({
"metrics": [{"name": "Monitored images", "value": images.len()}, {"name": "Up to date", "value": uptodate}, {"name": "Updates available", "value": updatable}, {"name": "Unknown", "value": unknown}],
"images": images,
"style": STYLE
});
let result = template.render(&globals).unwrap();
println!("Received {} request on {}", method, path.0);
WebResponse::new(ResponseBody::from(result))
}
async fn json(
updates: StateOwn<Vec<(String, Option<bool>)>>,
method: Method,
path: PathOwn,
) -> WebResponse {
let result_mutex: Mutex<json::object::Object> = Mutex::new(json::object::Object::new());
updates.par_iter().for_each(|image| match image.1 {
Some(b) => result_mutex.lock().unwrap().insert(&image.0, json::from(b)),
None => result_mutex.lock().unwrap().insert(&image.0, json::Null),
});
let result = json::stringify(result_mutex.lock().unwrap().clone());
println!("Received {} request on {}", method, path.0);
WebResponse::new(ResponseBody::from(result))
}

129
src/template.liquid Normal file
View File

@@ -0,0 +1,129 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<style>
{{ style }}
</style>
<style>
/* Kinda hacky, but thank you https://geary.co/internal-borders-css-grid/ */
.gi {
position: relative;
height: 100%;
}
.gi::before,
.gi::after {
content: '';
position: absolute;
background-color: #e5e7eb;
z-index: 1;
}
@media (prefers-color-scheme: dark) {
.gi::before, .gi::after {
background-color: #1f2937;
}
}
.gi::before {
inline-size: 1px;
block-size: 100vh;
inset-inline-start: -0.125rem;
}
.gi::after {
inline-size: 100vw;
block-size: 1px;
inset-inline-start: 0;
inset-block-start: -0.12rem;
}
</style>
</head>
<body>
<div class="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div class="lg:px-8 sm:px-6 px-4 max-w-[80rem] mx-auto h-full w-full">
<div class="max-w-[48rem] mx-auto h-full my-8">
<h1 class="text-6xl font-bold dark:text-white">Cup🥤</h1>
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
<dl class="lg:grid-cols-4 md:grid-cols-2 gap-1 grid-cols-1 grid overflow-hidden *:relative">
{% for metric in metrics %}
<div class="gi">
<div class="xl:px-8 px-6 py-4 gap-y-2 gap-x-4 justify-between align-baseline flex flex-col h-full">
<dt class="text-gray-500 dark:text-gray-400 leading-6 font-medium">{{ metric.name }}</dt>
<dd class="text-black dark:text-white tracking-tight leading-10 font-medium text-3xl flex-none w-full">
{{ metric.value }}
</dd>
</div>
</div>
{% endfor %}
</dl>
</div>
<div class="shadow-sm bg-white dark:bg-gray-900 rounded-md my-8">
<ul class="*:py-4 *:px-6 *:flex *:items-center *:gap-3 dark:divide-gray-800 divide-y dark:text-white">
{% for image in images %}
<li>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M21 16.008v-8.018a1.98 1.98 0 0 0 -1 -1.717l-7 -4.008a2.016 2.016 0 0 0 -2 0l-7 4.008c-.619 .355 -1 1.01 -1 1.718v8.018c0 .709 .381 1.363 1 1.717l7 4.008a2.016 2.016 0 0 0 2 0l7 -4.008c.619 -.355 1 -1.01 1 -1.718z" />
<path d="M12 22v-10" />
<path d="M12 12l8.73 -5.04" />
<path d="M3.27 6.96l8.73 5.04" />
</svg>
{{ image.name }}
{% if image.status == 'up-to-date' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-green-500 ml-auto"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-1.293 5.953a1 1 0 0 0 -1.32 -.083l-.094 .083l-3.293 3.292l-1.293 -1.292l-.094 -.083a1 1 0 0 0 -1.403 1.403l.083 .094l2 2l.094 .083a1 1 0 0 0 1.226 0l.094 -.083l4 -4l.083 -.094a1 1 0 0 0 -.083 -1.32z" />
</svg>
{% elsif image.status == 'update-available' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-blue-500 ml-auto"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 3.34a10 10 0 1 1 -14.995 8.984l-.005 -.324l.005 -.324a10 10 0 0 1 14.995 -8.336zm-4.98 3.66l-.163 .01l-.086 .016l-.142 .045l-.113 .054l-.07 .043l-.095 .071l-.058 .054l-4 4l-.083 .094a1 1 0 0 0 1.497 1.32l2.293 -2.293v5.586l.007 .117a1 1 0 0 0 1.993 -.117v-5.585l2.293 2.292l.094 .083a1 1 0 0 0 1.32 -1.497l-4 -4l-.082 -.073l-.089 -.064l-.113 -.062l-.081 -.034l-.113 -.034l-.112 -.02l-.098 -.006z" />
</svg>
{% elsif image.status == 'unknown' %}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
class="text-gray-500 ml-auto"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 2c5.523 0 10 4.477 10 10a10 10 0 0 1 -19.995 .324l-.005 -.324l.004 -.28c.148 -5.393 4.566 -9.72 9.996 -9.72zm0 13a1 1 0 0 0 -.993 .883l-.007 .117l.007 .127a1 1 0 0 0 1.986 0l.007 -.117l-.007 -.127a1 1 0 0 0 -.993 -.883zm1.368 -6.673a2.98 2.98 0 0 0 -3.631 .728a1 1 0 0 0 1.44 1.383l.171 -.18a.98 .98 0 0 1 1.11 -.15a1 1 0 0 1 -.34 1.886l-.232 .012a1 1 0 0 0 .111 1.994a3 3 0 0 0 1.371 -5.673z" />
</svg>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</body>
</html>

77
src/utils.rs Normal file
View File

@@ -0,0 +1,77 @@
use fancy_regex::Regex;
use once_cell::sync::Lazy;
#[macro_export]
macro_rules! error {
($($arg:tt)*) => ({
eprintln!($($arg)*);
std::process::exit(1);
})
}
// Takes an image and splits it into registry, repository and tag. For example ghcr.io/sergi0g/cup:latest becomes ['ghcr.io', 'sergi0g/cup', 'latest']. ONLY REGISTRIES THAT USE A / IN THE REPOSITORY ARE SUPPORTED CURRENTLY. THAT MEANS AZURE WILL NOT WORK.
pub fn split_image(image: &str) -> (String, String, String) {
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r#"^(?P<registry>[\w.\-_]+((?::\d+|)(?=/[a-z0-9._-]+/[a-z0-9._-]+))|)(?:/|)(?P<repository>[a-z0-9.\-_]+(?:/[a-z0-9.\-_]+|))(:(?P<tag>[\w.\-_]{1,127})|)$"#, // From https://regex101.com/r/a98UqN/1
)
.unwrap()
});
match RE.captures(image).unwrap() {
Some(c) => {
return (
match c.name("registry") {
Some(registry) => {
let reg = registry.as_str().to_owned();
if reg.is_empty() {
String::from("registry-1.docker.io")
} else {
reg
}
}
None => error!("Failed to parse image {}", image),
},
match c.name("repository") {
Some(repository) => {
let repo = repository.as_str().to_owned();
if !repo.contains('/') {
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),
}
}
pub fn unsplit_image(registry: &str, repository: &str, tag: &str) -> String {
let reg = match registry {
"registry-1.docker.io" => "",
r => &format!("{}/", r),
};
let repo = match repository.split('/').collect::<Vec<&str>>()[0] {
"library" => repository.strip_prefix("library/").unwrap(),
_ => repository,
};
format!("{}{}:{}", reg, repo, tag)
}
#[cfg(feature = "cli")]
pub fn sort_update_vec(updates: &[(String, Option<bool>)]) -> Vec<(String, Option<bool>)> {
let mut sorted_updates = updates.to_vec();
sorted_updates.sort_unstable_by(|a, b| match (a.1, b.1) {
(Some(a), Some(b)) => (!a).cmp(&!b),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
});
sorted_updates.to_vec()
}