Compare commits

...

7 Commits

Author SHA1 Message Date
Thomas Eppers
a41197562b nicer formatting for TagList
All checks were successful
continuous-integration/woodpecker the build was successful
2021-11-01 13:18:05 +01:00
Thomas Eppers
0ace41f545 removed clippy warnings 2021-11-01 13:09:09 +01:00
Thomas Eppers
cefe57b980 first working version with ghcr 2021-11-01 13:07:11 +01:00
Thomas Eppers
fe3f0579ad moved function to mod.rs; use generic structs in default.rs 2021-11-01 13:00:59 +01:00
Thomas Eppers
cb1a1c24b7 fixed some clippy warnings; added TODOs for later 2021-11-01 12:34:05 +01:00
Thomas Eppers
bc09317d4b fixed tests for DockerHub 2021-11-01 12:29:58 +01:00
Thomas Eppers
c0d376c79f created new generic structs for another registry than docker.hub 2021-11-01 12:27:51 +01:00
7 changed files with 348 additions and 182 deletions

View File

@ -2,7 +2,7 @@ use std::path::PathBuf;
use structopt::StructOpt;
mod repo;
mod tags;
mod repository;
mod ui;
mod widget;

View File

@ -0,0 +1,72 @@
use serde::Deserialize;
use crate::repository::Error;
#[derive(Deserialize, Debug, Clone)]
struct ImageDetails {
architecture: String,
size: usize,
}
#[derive(Deserialize, Clone)]
pub struct Images {
images: Vec<ImageDetails>,
#[serde(rename(deserialize = "name"))]
tag_name: String,
last_updated: String,
}
impl Images {
pub fn convert(&self) -> super::Tag {
super::Tag {
name: self.tag_name.clone(),
last_updated: Some(self.last_updated.clone()),
details: self
.images
.iter()
.map(|d| super::TagDetails {
arch: Some(d.architecture.clone()),
size: Some(d.size),
})
.collect(),
}
}
}
#[derive(Deserialize)]
pub struct DockerHub {
#[serde(rename(deserialize = "next"))]
next_page: Option<String>,
results: Vec<Images>,
}
impl DockerHub {
/// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker
pub fn create_repo(repo: &str) -> Result<super::Repo, Error> {
let request = format!("https://hub.docker.com/v2/repositories/{}/tags", repo);
Self::with_url(&request)
}
/// fetches tag information from a url
pub fn with_url(url: &str) -> Result<super::Repo, Error> {
let response = match reqwest::blocking::get(url) {
Ok(result) => result,
Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))),
};
//convert it to json
let tags = match response.json::<Self>() {
Ok(result) => result,
Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))),
};
if tags.results.is_empty() {
return Err(Error::NoTagsFound);
}
Ok(super::Repo {
tags: tags.results.iter().map(|t| t.convert()).collect(),
next_page: tags.next_page,
})
}
}

73
src/repository/ghcr.rs Normal file
View File

@ -0,0 +1,73 @@
use serde::Deserialize;
use crate::repository::Error;
#[derive(Deserialize)]
struct Token {
token: String,
}
#[derive(Deserialize)]
pub struct Ghcr {
tags: Vec<String>,
}
impl Ghcr {
/// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker
pub fn create_repo(repo: &str) -> Result<super::Repo, Error> {
let request_token = format!("https://ghcr.io/token?scope=repository:{}:pull", repo);
let response = match reqwest::blocking::get(request_token) {
Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))),
Ok(response) => response,
};
let token = match response.json::<Token>() {
Err(e) => return Err(Error::Converting(format!("invalid token json: {}", e))),
Ok(token) => token.token,
};
let request = format!("https://ghcr.io/v2/{}/tags/list?n=100", repo);
let client = reqwest::blocking::Client::new();
let response = match client
.get(request)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {}", token))
.send()
{
// let response = match reqwest::blocking::get(url) {
Ok(result) => result,
Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))),
};
//convert it to json
let tags = match response.json::<Self>() {
Ok(result) => result,
Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))),
};
if tags.tags.is_empty() {
return Err(Error::NoTagsFound);
}
Ok(super::Repo {
tags: tags
.tags
.iter()
.map(|t| super::Tag {
name: t.clone(),
details: vec![],
last_updated: None,
})
.collect(),
next_page: None,
})
}
}
#[cfg(test)]
mod tests {
use super::Ghcr;
#[test]
fn test_ghcr() {
Ghcr::create_repo("ghcr.io/linuxserver/beets").unwrap();
}
}

183
src/repository/mod.rs Normal file
View File

@ -0,0 +1,183 @@
mod dockerhub;
mod ghcr;
use std::fmt;
use chrono::DateTime;
use crate::repo;
#[derive(Debug, PartialEq)]
pub enum Error {
/// couldn't fetch json with reqwest
Fetching(String),
/// a serde error
Converting(String),
/// invalid repos show a valid json with 0 tags
NoTagsFound,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Fetching(s) => write!(f, "Fetching error: {}", s),
Error::Converting(s) => write!(f, "Converting error: {}", s),
Error::NoTagsFound => write!(f, "Given Repo has 0 tags. Is it valid?"),
}
}
}
#[derive(Clone)]
pub struct TagDetails {
arch: Option<String>,
size: Option<usize>,
}
impl fmt::Display for TagDetails {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let size = match self.size {
None => "".to_string(),
Some(s) => (s / 1024 / 1024).to_string(),
};
write!(
f,
"{}|{}MB",
self.arch.as_ref().unwrap_or(&"".to_string()),
size
)
}
}
#[derive(Clone)]
pub struct Tag {
name: String,
details: Vec<TagDetails>,
last_updated: Option<String>,
}
impl Tag {
pub fn get_name(&self) -> &str {
&self.name
}
pub fn get_name_with_details(&self) -> String {
//architecture infos
let mut arch = String::new();
for image in self.details.iter().take(1) {
arch.push_str(&format!("{}", image));
}
for image in self.details.iter().skip(1) {
arch.push_str(&format!(", {}", image));
}
let arch = if !arch.is_empty() {
format!(" [{}]", arch)
} else {
String::new()
};
let dif = match &self.last_updated {
None => "".to_string(),
Some(last_updated) => {
let now = chrono::Utc::now();
let rfc3339 = DateTime::parse_from_rfc3339(last_updated).unwrap();
let dif = now - rfc3339.with_timezone(&chrono::Utc);
format!(" vor {}", format_time_nice(dif))
}
};
if dif.is_empty() {}
format!("{}{}{}", self.name, dif, arch)
}
}
pub struct Repo {
tags: Vec<Tag>,
next_page: Option<String>,
}
impl Repo {
pub fn new(repo: &str) -> Result<Self, Error> {
use crate::repo::Repo;
let (registry, repo) = match crate::repo::split_repo_without_tag(repo) {
Ok(Repo::WithServer(reg, org, pro)) => (Some(reg), format!("{}/{}", org, pro)),
Ok(Repo::WithOrga(org, pro)) => (None, format!("{}/{}", org, pro)),
Ok(Repo::Project(pro)) => (None, format!("library/{}", pro)),
Err(e) => return Err(Error::Converting(format!("{}", e))),
};
if registry.unwrap_or_default() == "ghcr.io" {
ghcr::Ghcr::create_repo(&repo)
} else {
dockerhub::DockerHub::create_repo(&repo)
}
}
pub fn with_url(url: &str) -> Result<Self, Error> {
//TODO fix for other registries
dockerhub::DockerHub::with_url(url)
}
pub fn get_tags(&self) -> &Vec<Tag> {
&self.tags
}
pub fn next_page(&self) -> Option<Self> {
match &self.next_page {
Some(url) => match Self::with_url(url) {
Ok(tags) => Some(tags),
Err(_) => None,
},
None => None,
}
}
}
/// converts a given duration to a readable string
fn format_time_nice(time: chrono::Duration) -> String {
if time.num_weeks() == 52 {
format!("{} Jahr", (time.num_weeks() / 52) as i32)
} else if time.num_weeks() > 103 {
format!("{} Jahren", (time.num_weeks() / 52) as i32)
} else if time.num_days() == 1 {
format!("{} Tag", time.num_days())
} else if time.num_days() > 1 {
format!("{} Tagen", time.num_days())
} else if time.num_hours() == 1 {
format!("{} Stunde", time.num_hours())
} else if time.num_hours() > 1 {
format!("{} Stunden", time.num_hours())
} else if time.num_minutes() == 1 {
format!("{} Minute", time.num_minutes())
} else if time.num_minutes() > 1 {
format!("{} Minuten", time.num_minutes())
} else {
format!("{} Sekunden", time.num_seconds())
}
}
/// checks the repo name and may add a prefix for official images
pub fn check_repo(name: &str) -> Result<String, Error> {
let repo = match repo::split_tag_from_repo(name) {
Err(e) => return Err(Error::Converting(format!("{}", e))),
Ok((name, _)) => name,
};
match repo::split_repo_without_tag(name) {
Ok(repo::Repo::Project(s)) => Ok(format!("library/{}", s)),
Ok(_) => Ok(repo.to_string()),
Err(e) => Err(Error::Converting(format!("{}", e))),
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_check_repo() {
assert_eq!(super::check_repo("nginx").unwrap(), "library/nginx");
assert_eq!(super::check_repo("library/nginx").unwrap(), "library/nginx");
assert_eq!(
super::check_repo("rocketchat/rocket.chat").unwrap(),
"rocketchat/rocket.chat"
);
}
}

View File

@ -1,168 +0,0 @@
use std::fmt;
use crate::repo;
use chrono::DateTime;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
struct ImageDetails {
architecture: String,
size: usize,
}
impl fmt::Display for ImageDetails {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}|{}MB", self.architecture, self.size / 1024 / 1024)
}
}
#[derive(Deserialize, Clone)]
pub struct Images {
images: Vec<ImageDetails>,
#[serde(rename(deserialize = "name"))]
pub tag_name: String,
last_updated: String,
}
impl fmt::Display for Images {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
//architecture infos
let mut arch = String::new();
for image in self.images.iter().take(1) {
arch.push_str(&format!("{}", image));
}
for image in self.images.iter().skip(1) {
arch.push_str(&format!(", {}", image));
}
let now = chrono::Utc::now();
let rfc3339 = DateTime::parse_from_rfc3339(&self.last_updated).unwrap();
let dif = now - rfc3339.with_timezone(&chrono::Utc);
write!(
f,
"{} vor {} [{}]",
self.tag_name,
format_time_nice(dif),
arch
)
}
}
#[derive(Deserialize)]
pub struct Tags {
count: usize,
#[serde(rename(deserialize = "next"))]
pub next_page: Option<String>,
pub results: Vec<Images>,
}
#[derive(Debug, PartialEq)]
pub enum Error {
/// couldn't fetch json with reqwest
Fetching(String),
/// a serde error
Converting(String),
/// invalid repos show a valid json with 0 tags
NoTagsFound,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Fetching(s) => write!(f, "Fetching error: {}", s),
Error::Converting(s) => write!(f, "Converting error: {}", s),
Error::NoTagsFound => write!(f, "Given Repo has 0 tags. Is it valid?"),
}
}
}
impl Tags {
/// fetches tag information with a repository name in the form of organization/repository or library/repository in the case of official images from docker
pub fn new(repo: String) -> Result<Self, Error> {
let request = format!("https://hub.docker.com/v2/repositories/{}/tags", repo);
Self::with_url(&request)
}
/// fetches tag information from a url
fn with_url(url: &str) -> Result<Self, Error> {
let res = match reqwest::blocking::get(url) {
Ok(result) => result,
Err(e) => return Err(Error::Fetching(format!("reqwest error: {}", e))),
};
//convert it to json
let raw = res.text().unwrap();
let tags: Self = match serde_json::from_str(&raw) {
Ok(result) => result,
Err(e) => return Err(Error::Converting(format!("invalid json: {}", e))),
};
if tags.count == 0 {
return Err(Error::NoTagsFound);
}
Ok(tags)
}
/// checks the repo name and may add a prefix for official images
pub fn check_repo(name: &str) -> Result<String, Error> {
let repo = match repo::split_tag_from_repo(name) {
Err(e) => return Err(Error::Converting(format!("{}", e))),
Ok((name, _)) => name,
};
match repo::split_repo_without_tag(name) {
Ok(repo::Repo::Project(s)) => Ok(format!("library/{}", s)),
Ok(_) => Ok(repo.to_string()),
Err(e) => Err(Error::Converting(format!("{}", e))),
}
}
/// returns tags of next page
pub fn next_page(&self) -> Option<Self> {
match &self.next_page {
Some(url) => match Self::with_url(url) {
Ok(tags) => Some(tags),
Err(_) => None,
},
None => None,
}
}
}
/// converts a given duration to a readable string
fn format_time_nice(time: chrono::Duration) -> String {
if time.num_weeks() == 52 {
format!("{} Jahr", (time.num_weeks() / 52) as i32)
} else if time.num_weeks() > 103 {
format!("{} Jahren", (time.num_weeks() / 52) as i32)
} else if time.num_days() == 1 {
format!("{} Tag", time.num_days())
} else if time.num_days() > 1 {
format!("{} Tagen", time.num_days())
} else if time.num_hours() == 1 {
format!("{} Stunde", time.num_hours())
} else if time.num_hours() > 1 {
format!("{} Stunden", time.num_hours())
} else if time.num_minutes() == 1 {
format!("{} Minute", time.num_minutes())
} else if time.num_minutes() > 1 {
format!("{} Minuten", time.num_minutes())
} else {
format!("{} Sekunden", time.num_seconds())
}
}
#[cfg(test)]
mod tests {
use crate::tags::Tags;
#[test]
fn test_check_repo() {
assert_eq!(Tags::check_repo("nginx").unwrap(), "library/nginx");
assert_eq!(Tags::check_repo("library/nginx").unwrap(), "library/nginx");
assert_eq!(
Tags::check_repo("rocketchat/rocket.chat").unwrap(),
"rocketchat/rocket.chat"
);
}
}

View File

@ -7,6 +7,7 @@ use tui::backend::TermionBackend;
use tui::layout::{Constraint, Direction, Layout};
use tui::Terminal;
use crate::repository;
use crate::widget::info;
use crate::widget::repo_entry;
use crate::widget::service_switcher;
@ -156,7 +157,7 @@ impl Ui {
match ui.services.extract_repo() {
Err(e) => ui.info.set_info(&format!("{}", e)),
Ok(s) => {
let repo = match crate::tags::Tags::check_repo(&s) {
let repo = match repository::check_repo(&s) {
Err(e) => {
ui.info.set_info(&format!("{}", e));
continue;
@ -177,7 +178,7 @@ impl Ui {
match ui.services.extract_repo() {
Err(e) => ui.info.set_info(&format!("{}", e)),
Ok(s) => {
let repo = match crate::tags::Tags::check_repo(&s) {
let repo = match repository::check_repo(&s) {
Err(e) => {
ui.info.set_info(&format!("{}", e));
continue;

View File

@ -4,7 +4,7 @@ use termion::event::Key;
use tui::style::{Color, Style};
use tui::widgets::{Block, Borders, List, ListState};
use crate::tags;
use crate::repository;
pub enum Error {
NoneSelected,
@ -24,7 +24,7 @@ impl fmt::Display for Error {
enum Line {
Status(String),
Image(tags::Images),
Image(repository::Tag),
NextPage(String),
}
@ -32,7 +32,7 @@ impl fmt::Display for Line {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Line::Status(s) => write!(f, "{}", s),
Line::Image(i) => write!(f, "{}", i),
Line::Image(i) => write!(f, "{}", i.get_name_with_details()),
Line::NextPage(s) => write!(f, "{}", s),
}
}
@ -41,7 +41,7 @@ impl fmt::Display for Line {
pub struct TagList {
lines: Vec<Line>,
state: ListState,
tags: Option<tags::Tags>,
tags: Option<repository::Repo>,
}
impl TagList {
@ -54,15 +54,15 @@ impl TagList {
}
pub fn with_repo_name(repo: String) -> Self {
match tags::Tags::new(repo) {
match repository::Repo::new(&repo) {
Ok(tags) => Self::with_tags(tags),
Err(_) => Self::with_status("input repo was not found"),
}
}
pub fn with_tags(mut tags: tags::Tags) -> Self {
pub fn with_tags(mut tags: repository::Repo) -> Self {
let mut lines: Vec<Line> = tags
.results
.get_tags()
.iter()
.map(|r| Line::Image(r.clone()))
.collect();
@ -136,7 +136,7 @@ impl TagList {
None => Err(Error::NoneSelected),
Some(i) => match &self.lines[i] {
Line::Status(_) => Err(Error::SelectedStatus),
Line::Image(i) => Ok(i.tag_name.clone()),
Line::Image(i) => Ok(i.get_name().to_string()),
Line::NextPage(_) => {
self.load_next_page();
Err(Error::NextPageSelected)
@ -158,12 +158,17 @@ impl TagList {
let next_page = self.lines.pop();
//add tags
for image in &self.tags.as_ref().unwrap().results {
self.lines.push(Line::Image(image.clone()));
match &self.tags {
None => (),
Some(tags) => {
for image in tags.get_tags().iter() {
self.lines.push(Line::Image(image.clone()));
}
}
}
//readd next page
match self.tags.as_ref().unwrap().next_page {
match self.tags.as_ref().unwrap().next_page() {
None => (),
Some(_) => self.lines.push(next_page.unwrap()),
}