mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-16 17:13:46 -05:00
Nearly complete versioning support. Fixed old bugs where not all tags were fetched.
This commit is contained in:
166
src/structs/image.rs
Normal file
166
src/structs/image.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use json::{object, JsonValue};
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
http::Client,
|
||||
registry::{get_latest_digest, get_latest_tag},
|
||||
structs::{status::Status, version::Version},
|
||||
utils::reference::split,
|
||||
};
|
||||
|
||||
use super::inspectdata::InspectData;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct DigestInfo {
|
||||
pub local_digests: Vec<String>,
|
||||
pub remote_digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct VersionInfo {
|
||||
pub current_tag: Version,
|
||||
pub latest_remote_tag: Option<Version>,
|
||||
}
|
||||
|
||||
/// Image struct that contains all information that may be needed by a function working with an image.
|
||||
/// It's designed to be passed around between functions
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct Image {
|
||||
pub reference: String,
|
||||
pub registry: String,
|
||||
pub repository: String,
|
||||
pub tag: String,
|
||||
pub digest_info: Option<DigestInfo>,
|
||||
pub version_info: Option<VersionInfo>,
|
||||
pub error: Option<String>,
|
||||
pub time_ms: i64,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
/// Creates an populates the fields of an Image object based on the ImageSummary from the Docker daemon
|
||||
pub async fn from_inspect_data<T: InspectData>(image: T) -> Option<Self> {
|
||||
let tags = image.tags().unwrap();
|
||||
let digests = image.digests().unwrap();
|
||||
if !tags.is_empty() && !digests.is_empty() {
|
||||
let reference = tags[0].clone();
|
||||
let (registry, repository, tag) = split(&reference);
|
||||
let version_tag = Version::from_tag(&tag);
|
||||
let local_digests = digests
|
||||
.iter()
|
||||
.map(|digest| digest.split('@').collect::<Vec<&str>>()[1].to_string())
|
||||
.collect();
|
||||
Some(Self {
|
||||
reference,
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
digest_info: Some(DigestInfo {
|
||||
local_digests,
|
||||
remote_digest: None,
|
||||
}),
|
||||
version_info: version_tag.map(|stag| VersionInfo {
|
||||
current_tag: stag,
|
||||
latest_remote_tag: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares remote digest of the image with its local digests to determine if it has an update or not
|
||||
pub fn has_update(&self) -> Status {
|
||||
if self.error.is_some() {
|
||||
Status::Unknown(self.error.clone().unwrap())
|
||||
} else {
|
||||
match &self.version_info {
|
||||
Some(data) => data
|
||||
.latest_remote_tag
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_status(&data.current_tag),
|
||||
None => match &self.digest_info {
|
||||
Some(data) => {
|
||||
if data
|
||||
.local_digests
|
||||
.contains(data.remote_digest.as_ref().unwrap())
|
||||
{
|
||||
Status::UpToDate
|
||||
} else {
|
||||
Status::UpdateAvailable
|
||||
}
|
||||
}
|
||||
None => unreachable!(), // I hope?
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts image data into a `JsonValue`
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
let has_update = self.has_update();
|
||||
let update_type = match has_update {
|
||||
Status::UpdateMajor | Status::UpdateMinor | Status::UpdatePatch => "version",
|
||||
_ => "digest",
|
||||
};
|
||||
object! {
|
||||
reference: self.reference.clone(),
|
||||
parts: object! {
|
||||
registry: self.registry.clone(),
|
||||
repository: self.repository.clone(),
|
||||
tag: self.tag.clone()
|
||||
},
|
||||
result: object! {
|
||||
has_update: has_update.to_option_bool(),
|
||||
info: match has_update {
|
||||
Status::Unknown(_) => None,
|
||||
_ => Some(match update_type {
|
||||
"version" => {
|
||||
let (version_tag, latest_remote_tag) = match &self.version_info {
|
||||
Some(data) => (data.current_tag.clone(), data.latest_remote_tag.clone()),
|
||||
_ => unreachable!()
|
||||
};
|
||||
object! {
|
||||
"type": update_type,
|
||||
version_update_type: match has_update {
|
||||
Status::UpdateMajor => "major",
|
||||
Status::UpdateMinor => "minor",
|
||||
Status::UpdatePatch => "patch",
|
||||
_ => unreachable!()
|
||||
},
|
||||
new_version: self.tag.replace(&version_tag.to_string(), &latest_remote_tag.as_ref().unwrap().to_string())
|
||||
}
|
||||
},
|
||||
"digest" => {
|
||||
let (local_digests, remote_digest) = match &self.digest_info {
|
||||
Some(data) => (data.local_digests.clone(), data.remote_digest.clone()),
|
||||
_ => unreachable!()
|
||||
};
|
||||
object! {
|
||||
"type": update_type,
|
||||
local_digests: local_digests,
|
||||
remote_digest: remote_digest,
|
||||
}
|
||||
},
|
||||
_ => unreachable!()
|
||||
})
|
||||
}},
|
||||
time: self.time_ms
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the image has an update
|
||||
pub async fn check(&self, token: Option<&String>, config: &Config, client: &Client) -> Self {
|
||||
match &self.version_info {
|
||||
Some(data) => get_latest_tag(self, &data.current_tag, token, config, client).await,
|
||||
None => match self.digest_info {
|
||||
Some(_) => get_latest_digest(self, token, config, client).await,
|
||||
None => unreachable!(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/structs/inspectdata.rs
Normal file
26
src/structs/inspectdata.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use bollard::secret::{ImageInspect, ImageSummary};
|
||||
|
||||
pub trait InspectData {
|
||||
fn tags(&self) -> Option<Vec<String>>;
|
||||
fn digests(&self) -> Option<Vec<String>>;
|
||||
}
|
||||
|
||||
impl InspectData for ImageInspect {
|
||||
fn tags(&self) -> Option<Vec<String>> {
|
||||
self.repo_tags.clone()
|
||||
}
|
||||
|
||||
fn digests(&self) -> Option<Vec<String>> {
|
||||
self.repo_digests.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectData for ImageSummary {
|
||||
fn tags(&self) -> Option<Vec<String>> {
|
||||
Some(self.repo_tags.clone())
|
||||
}
|
||||
|
||||
fn digests(&self) -> Option<Vec<String>> {
|
||||
Some(self.repo_digests.clone())
|
||||
}
|
||||
}
|
||||
4
src/structs/mod.rs
Normal file
4
src/structs/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod image;
|
||||
pub mod inspectdata;
|
||||
pub mod status;
|
||||
pub mod version;
|
||||
36
src/structs/status.rs
Normal file
36
src/structs/status.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Enum for image status
|
||||
#[derive(Ord, Eq, PartialEq, PartialOrd)]
|
||||
pub enum Status {
|
||||
UpdateMajor,
|
||||
UpdateMinor,
|
||||
UpdatePatch,
|
||||
UpdateAvailable,
|
||||
UpToDate,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl Display for Status {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match &self {
|
||||
Self::UpToDate => "Up to date",
|
||||
Self::UpdateAvailable => "Update available",
|
||||
Self::UpdateMajor => "Major update",
|
||||
Self::UpdateMinor => "Minor update",
|
||||
Self::UpdatePatch => "Patch update",
|
||||
Self::Unknown(_) => "Unknown",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Status {
|
||||
// Converts the Status into an Option<bool> (useful for JSON serialization)
|
||||
pub fn to_option_bool(&self) -> Option<bool> {
|
||||
match &self {
|
||||
Self::UpToDate => Some(false),
|
||||
Self::Unknown(_) => None,
|
||||
_ => Some(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
164
src/structs/version.rs
Normal file
164
src/structs/version.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use std::{cmp::Ordering, fmt::Display};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
use super::status::Status;
|
||||
|
||||
/// Heavily modified version of the official semver regex based on common tagging schemes for container images. Sometimes it matches more than once, but we'll try to select the best match. Yes, there _will_ be errors.
|
||||
static SEMVER_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r#"(?P<major>0|[1-9]\d*)(?:\.(?P<minor>0|[1-9]\d*))?(?:\.(?P<patch>0|[1-9]\d*)+)?"#)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// Semver-like version struct
|
||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: Option<u32>,
|
||||
pub patch: Option<u32>,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
/// Tries to parse the tag into semver-like parts. Should have been included in impl Image, but that would make the tests more complicated
|
||||
pub fn from_tag(tag: &str) -> Option<Self> {
|
||||
let captures = SEMVER_REGEX.captures_iter(tag);
|
||||
// And now... terrible best match selection for everyone!
|
||||
let mut max_matches = 0;
|
||||
let mut best_match = None;
|
||||
for capture in captures {
|
||||
let mut count = 0;
|
||||
for idx in 1..capture.len() {
|
||||
if capture.get(idx).is_some() {
|
||||
count += 1
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if count > max_matches {
|
||||
max_matches = count;
|
||||
best_match = Some(capture);
|
||||
}
|
||||
}
|
||||
match best_match {
|
||||
Some(c) => {
|
||||
let major: u32 = match c.name("major") {
|
||||
Some(major) => major.as_str().parse().unwrap(),
|
||||
None => return None,
|
||||
};
|
||||
let minor: Option<u32> =
|
||||
c.name("minor").map(|minor| minor.as_str().parse().unwrap());
|
||||
let patch: Option<u32> =
|
||||
c.name("patch").map(|patch| patch.as_str().parse().unwrap());
|
||||
Some(Version {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_status(&self, base: &Self) -> Status {
|
||||
if self.major == base.major {
|
||||
match (self.minor, base.minor) {
|
||||
(Some(a_minor), Some(b_minor)) => {
|
||||
if a_minor == b_minor {
|
||||
match (self.patch, base.patch) {
|
||||
(Some(a_patch), Some(b_patch)) => {
|
||||
if a_patch == b_patch {
|
||||
Status::UpToDate
|
||||
} else {
|
||||
Status::UpdatePatch
|
||||
}
|
||||
}
|
||||
(None, None) => Status::UpToDate,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
Status::UpdateMinor
|
||||
}
|
||||
}
|
||||
(None, None) => Status::UpToDate,
|
||||
_ => unreachable!(
|
||||
"Version error: {} and {} should either both be Some or None",
|
||||
self, base
|
||||
),
|
||||
}
|
||||
} else {
|
||||
Status::UpdateMajor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
let major_ordering = self.major.cmp(&other.major);
|
||||
match major_ordering {
|
||||
Ordering::Equal => match (self.minor, other.minor) {
|
||||
(Some(self_minor), Some(other_minor)) => {
|
||||
let minor_ordering = self_minor.cmp(&other_minor);
|
||||
match minor_ordering {
|
||||
Ordering::Equal => match (self.patch, other.patch) {
|
||||
(Some(self_patch), Some(other_patch)) => self_patch.cmp(&other_patch),
|
||||
_ => Ordering::Equal,
|
||||
},
|
||||
_ => minor_ordering,
|
||||
}
|
||||
}
|
||||
_ => Ordering::Equal,
|
||||
},
|
||||
_ => major_ordering,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&format!(
|
||||
"{}{}{}",
|
||||
self.major,
|
||||
match self.minor {
|
||||
Some(minor) => format!(".{}", minor),
|
||||
None => String::new(),
|
||||
},
|
||||
match self.patch {
|
||||
Some(patch) => format!(".{}", patch),
|
||||
None => String::new(),
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[rustfmt::skip]
|
||||
fn version() {
|
||||
assert_eq!(Version::from_tag("5.3.2" ), Some(Version { major: 5, minor: Some(3), patch: Some(2) }));
|
||||
assert_eq!(Version::from_tag("14" ), Some(Version { major: 14, minor: Some(0), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("v0.107.53" ), Some(Version { major: 0, minor: Some(107), patch: Some(53) }));
|
||||
assert_eq!(Version::from_tag("12-alpine" ), Some(Version { major: 12, minor: Some(0), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("0.9.5-nginx" ), Some(Version { major: 0, minor: Some(9), patch: Some(5) }));
|
||||
assert_eq!(Version::from_tag("v27.0" ), Some(Version { major: 27, minor: Some(0), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("16.1" ), Some(Version { major: 16, minor: Some(1), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("version-1.5.6" ), Some(Version { major: 1, minor: Some(5), patch: Some(6) }));
|
||||
assert_eq!(Version::from_tag("15.4-alpine" ), Some(Version { major: 15, minor: Some(4), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("pg14-v0.2.0" ), Some(Version { major: 0, minor: Some(2), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("18-jammy-full.s6-v0.88.0"), Some(Version { major: 0, minor: Some(88), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("fpm-2.1.0-prod" ), Some(Version { major: 2, minor: Some(1), patch: Some(0) }));
|
||||
assert_eq!(Version::from_tag("7.3.3.50" ), Some(Version { major: 7, minor: Some(3), patch: Some(3) }));
|
||||
assert_eq!(Version::from_tag("1.21.11-0" ), Some(Version { major: 1, minor: Some(21), patch: Some(11) }));
|
||||
assert_eq!(Version::from_tag("4.1.2.1-full" ), Some(Version { major: 4, minor: Some(1), patch: Some(2) }));
|
||||
assert_eq!(Version::from_tag("v4.0.3-ls215" ), Some(Version { major: 4, minor: Some(0), patch: Some(3) }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user