mirror of
https://github.com/sergi0g/cup.git
synced 2025-11-10 14:13:49 -05:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c745a249f5 | ||
|
|
90af772dd7 | ||
|
|
6ab06db5cb | ||
|
|
547d418401 | ||
|
|
54f00c6c61 | ||
|
|
b55ddfd3ad | ||
|
|
14887cb766 | ||
|
|
b0d0a02182 | ||
|
|
c351a38642 | ||
|
|
ebb7c18bca |
13
Cargo.lock
generated
13
Cargo.lock
generated
@@ -164,9 +164,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bollard"
|
||||
version = "0.19.0"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af706e9dc793491dd382c99c22fde6e9934433d4cc0d6a4b34eb2cdc57a5c917"
|
||||
checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bollard-stubs",
|
||||
@@ -197,12 +197,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bollard-stubs"
|
||||
version = "1.48.2-rc.28.0.4"
|
||||
version = "1.47.1-rc.27.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79cdf0fccd5341b38ae0be74b74410bdd5eceeea8876dc149a13edfe57e3b259"
|
||||
checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"serde_with",
|
||||
]
|
||||
@@ -377,7 +376,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cup"
|
||||
version = "3.4.1"
|
||||
version = "3.4.2"
|
||||
dependencies = [
|
||||
"bollard",
|
||||
"chrono",
|
||||
@@ -2557,8 +2556,6 @@ checksum = "dd4f8f16791ea2a8845f617f1e87887f917835e0603d01f03a51e638b9613d0c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"xitca-http",
|
||||
"xitca-server",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
[package]
|
||||
name = "cup"
|
||||
version = "3.4.1"
|
||||
version = "3.4.3"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.7", features = ["derive"] }
|
||||
indicatif = { version = "0.17.8", optional = true }
|
||||
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
|
||||
xitca-web = { version = "0.6.2", optional = true, features = ["json"]}
|
||||
xitca-web = { version = "0.6.2", optional = true }
|
||||
liquid = { version = "0.26.6", optional = true }
|
||||
bollard = "0.19.0"
|
||||
bollard = "0.18.1"
|
||||
once_cell = "1.19.0"
|
||||
http-auth = { version = "0.1.9", default-features = false }
|
||||
termsize = { version = "0.1.8", optional = true }
|
||||
|
||||
17
NOTICE.md
Normal file
17
NOTICE.md
Normal file
@@ -0,0 +1,17 @@
|
||||
Hello,
|
||||
|
||||
I have an important announcement to make.
|
||||
|
||||
### The situation
|
||||
|
||||
You may have noticed that the last few months Cup's development has stalled. I had very little time to work on it. Tomorrow, the 11th of September, 2025, I am starting my last year of school. It is very important to me to get into a good university, so I will be studying all day, with no time to work on my projects.
|
||||
|
||||
What this means for you is that the development of Cup is paused, at least until June 2026. That means no new features and no bugfixes. If Cup works fine for you and you're comfortable with not getting any updates (this does _not_ mean Cup is suddenly insecure by the way), you can keep using it. Otherwise, there are many alternatives you can look at, which are actively maintained and may provide the features you need.
|
||||
|
||||
### How you can help
|
||||
|
||||
If you're a Rust developer, I would really appreciate it if you could start contributing to Cup. There are a bunch of open issues that need to be worked on. I can find some time to review PRs. You can also fork the repository and do your own thing, if you prefer.
|
||||
|
||||
I've also left a few new features in the `v4` branch and a rewrite I started in `rewrite`, because I feel like the code is unmaintainable in its current state. I'd love it if someone could help work on that.
|
||||
|
||||
That's all I had to say. I'm sorry if I let you down, this was a really hard decision to make. I would like to thank all of you for your help and support, it really means a lot to me. I hope I can continue working on the project once I'm done with my final exams!
|
||||
@@ -7,6 +7,8 @@
|
||||

|
||||
[](https://discord.gg/jmh5ctzwNG)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> There have been some important changes regarding Cup's development. Read more [here](./NOTICE.md).
|
||||
|
||||
Cup is the easiest way to check for container image updates.
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
},
|
||||
"socket": {
|
||||
"type": "string",
|
||||
"description": "The path to the unix socket you would like Cup to use for communication with the Docker daemon. Useful if you're trying to use Cup with Podman.",
|
||||
"description": "The path to the unix socket you would like Cup to use for communication with the Docker daemon. Useful if you're trying to use Cup with Podman. To disable use \"none\" as value.",
|
||||
"minLength": 1
|
||||
},
|
||||
"servers": {
|
||||
|
||||
@@ -51,29 +51,24 @@ Credit: [@agrmohit](https://github.com/agrmohit)
|
||||
```yaml
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://<SERVER_IP>:9000/api/v3/json
|
||||
url: http://<SERVER_HOSTNAME>/api/v3/json
|
||||
refreshInterval: 10000
|
||||
method: GET
|
||||
mappings:
|
||||
- field:
|
||||
metrics: monitored_images
|
||||
- field: metrics.monitored_images
|
||||
label: Monitored images
|
||||
format: number
|
||||
- field:
|
||||
metrics: up_to_date
|
||||
- field: metrics.up_to_date
|
||||
label: Up to date
|
||||
format: number
|
||||
- field:
|
||||
metrics: updates_available
|
||||
- field: metrics.updates_available
|
||||
label: Available updates
|
||||
format: number
|
||||
- field:
|
||||
metrics: unknown
|
||||
- field: metrics.unknown
|
||||
label: Unknown
|
||||
format: number
|
||||
```
|
||||
|
||||
Preview:
|
||||
|
||||
<Image src={widget2} />
|
||||
Credit: [@remussamoila](https://github.com/remussamoila)
|
||||
Credit: [@remussamoila](https://github.com/remussamoila) and [@Valdr687](https://github.com/Valdr687)
|
||||
@@ -23,7 +23,7 @@ For example, if using Podman, you might do
|
||||
$ cup -s /run/user/1000/podman/podman.sock check
|
||||
```
|
||||
|
||||
This option is also available in the configuration file and it's best to put it there.
|
||||
This option is also available in the configuration file and it's best to put it there. If both are defined the CLI `-s` option takes precedence.
|
||||
|
||||
<Cards.Card
|
||||
icon={<IconPlug />}
|
||||
@@ -31,6 +31,8 @@ This option is also available in the configuration file and it's best to put it
|
||||
href="/docs/configuration/socket"
|
||||
/>
|
||||
|
||||
To disable Docker/Podman socket use "none" as value.
|
||||
|
||||
## Configuration file
|
||||
|
||||
Cup has an option to be configured from a configuration file named `cup.json`.
|
||||
@@ -131,11 +133,11 @@ services:
|
||||
ports:
|
||||
- 8000:8000
|
||||
environment:
|
||||
- CUP_AGENT: true
|
||||
- CUP_IGNORE_UPDATE_TYPE: major
|
||||
- CUP_REFRESH_INTERVAL: "0 */30 * * * *"
|
||||
- CUP_SOCKET: tcp://localhost:2375
|
||||
- CUP_THEME: blue
|
||||
CUP_AGENT: "true"
|
||||
CUP_IGNORE_UPDATE_TYPE: major
|
||||
CUP_REFRESH_INTERVAL: "0 */30 * * * *"
|
||||
CUP_SOCKET: tcp://localhost:2375
|
||||
CUP_THEME: blue
|
||||
```
|
||||
|
||||
<Callout>
|
||||
|
||||
@@ -17,3 +17,12 @@ You can also specify a TCP socket if you're using a remote Docker host or a [pro
|
||||
// Other options
|
||||
}
|
||||
```
|
||||
|
||||
Or use the "none" value to disable any Docker/Podman query:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"socket": "none"
|
||||
// Other options
|
||||
}
|
||||
```
|
||||
|
||||
16
src/check.rs
16
src/check.rs
@@ -80,24 +80,22 @@ async fn get_remote_updates(ctx: &Context, client: &Client, refresh: bool) -> Ve
|
||||
}
|
||||
|
||||
/// Returns a list of updates for all images passed in.
|
||||
/// TODO: Completely rewrite this and make nothing is missed
|
||||
pub async fn get_updates(
|
||||
references: &Option<Vec<String>>, // If a user requested _specific_ references to be checked, this will have a value
|
||||
refresh: bool,
|
||||
ctx: &Context,
|
||||
) -> Vec<Update> {
|
||||
let client = Client::new(ctx);
|
||||
|
||||
|
||||
// Merge references argument with references from config
|
||||
let all_references = match &references {
|
||||
Some(refs) => {
|
||||
if !ctx.config.extra_images.is_empty() {
|
||||
refs.clone().extend_from_slice(&ctx.config.extra_images);
|
||||
if !ctx.config.images.extra.is_empty() {
|
||||
refs.clone().extend_from_slice(&ctx.config.images.extra);
|
||||
}
|
||||
refs.clone().extend_from_slice(&ctx.config.images.iter().filter(|(_, cfg)| cfg.include).map(|(reference, _)| reference).cloned().collect::<Vec<String>>());
|
||||
refs
|
||||
}
|
||||
None => &ctx.config.extra_images,
|
||||
None => &ctx.config.images.extra,
|
||||
};
|
||||
|
||||
// Get local images
|
||||
@@ -109,8 +107,8 @@ pub async fn get_updates(
|
||||
|
||||
// Complete in_use field
|
||||
images.iter_mut().for_each(|image| {
|
||||
if let Some(images) = in_use_images.get(&image.reference) {
|
||||
image.used_by = images.clone()
|
||||
if in_use_images.contains(&image.reference) {
|
||||
image.in_use = true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -209,9 +207,7 @@ pub async fn get_updates(
|
||||
}
|
||||
// Await all the futures
|
||||
let images = join_all(handles).await;
|
||||
|
||||
let mut updates: Vec<Update> = images.iter().map(|image| image.to_update()).collect();
|
||||
|
||||
updates.extend_from_slice(&remote_updates);
|
||||
updates
|
||||
}
|
||||
|
||||
@@ -51,21 +51,11 @@ pub struct RegistryConfig {
|
||||
pub ignore: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Default, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[non_exhaustive]
|
||||
pub enum TagType {
|
||||
#[default]
|
||||
Standard,
|
||||
Extended
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ImageConfig {
|
||||
pub include: bool, // Takes precedence over extra_images and excluded_images
|
||||
pub tag_type: TagType,
|
||||
pub ignore: UpdateType
|
||||
pub extra: Vec<String>,
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -73,9 +63,8 @@ pub struct ImageConfig {
|
||||
pub struct Config {
|
||||
version: u8,
|
||||
pub agent: bool,
|
||||
pub images: FxHashMap<String, ImageConfig>,
|
||||
pub extra_images: Vec<String>, // These two are here for convenience, using `images` for this purpose should also work.
|
||||
pub excluded_images: Vec<String>, // Takes precedence over extra_images
|
||||
pub ignore_update_type: UpdateType,
|
||||
pub images: ImageConfig,
|
||||
#[serde(deserialize_with = "empty_as_none")]
|
||||
pub refresh_interval: Option<String>,
|
||||
pub registries: FxHashMap<String, RegistryConfig>,
|
||||
@@ -84,15 +73,13 @@ pub struct Config {
|
||||
pub theme: Theme,
|
||||
}
|
||||
|
||||
// TODO: Add helper methods that abstact away complex logic (i.e. functions that return all excluded images, extra images, etc based on the precedence rules set)
|
||||
impl Config {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version: 3,
|
||||
agent: false,
|
||||
images: FxHashMap::default(),
|
||||
extra_images: Vec::new(),
|
||||
excluded_images: Vec::new(),
|
||||
ignore_update_type: UpdateType::default(),
|
||||
images: ImageConfig::default(),
|
||||
refresh_interval: None,
|
||||
registries: FxHashMap::default(),
|
||||
servers: FxHashMap::default(),
|
||||
@@ -116,11 +103,11 @@ impl Config {
|
||||
match key.as_str() {
|
||||
"CUP_AGENT" => config.agent = cfg.agent,
|
||||
#[rustfmt::skip]
|
||||
"CUP_IGNORE_UPDATE_TYPE" => swap!(config.ignore_update_type, cfg.ignore_update_type),
|
||||
#[rustfmt::skip]
|
||||
"CUP_REFRESH_INTERVAL" => swap!(config.refresh_interval, cfg.refresh_interval),
|
||||
"CUP_SOCKET" => swap!(config.socket, cfg.socket),
|
||||
"CUP_THEME" => swap!(config.theme, cfg.theme),
|
||||
"CUP_EXTRA_IMAGES" => swap!(config.extra_images, cfg.extra_images),
|
||||
"CUP_EXCLUDED_IMAGES" => swap!(config.excluded_images, cfg.excluded_images),
|
||||
// The syntax for these is slightly more complicated, not sure if they should be enabled or not. Let's stick to simple types for now.
|
||||
// "CUP_IMAGES" => swap!(config.images, cfg.images),
|
||||
// "CUP_REGISTRIES" => swap!(config.registries, cfg.registries),
|
||||
@@ -154,8 +141,8 @@ impl Config {
|
||||
Ok(config) => config,
|
||||
Err(e) => error!("Unexpected error occured while parsing config: {}", e),
|
||||
};
|
||||
if config.version != 4 {
|
||||
error!("You are trying to run Cup with an incompatible config file! Please migrate your config file to the version 4, or if you have already done so, add a `version` key with the value `4`.")
|
||||
if config.version != 3 {
|
||||
error!("You are trying to run Cup with an incompatible config file! Please migrate your config file to the version 3, or if you have already done so, add a `version` key with the value `3`.")
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
277
src/docker.rs
277
src/docker.rs
@@ -1,26 +1,8 @@
|
||||
use bollard::{
|
||||
models::ImageInspect,
|
||||
query_parameters::{
|
||||
CreateContainerOptionsBuilder, CreateImageOptionsBuilder, InspectContainerOptions,
|
||||
ListContainersOptionsBuilder, ListImagesOptions, ListServicesOptions,
|
||||
RemoveContainerOptions, RenameContainerOptions, StartContainerOptions,
|
||||
StopContainerOptions,
|
||||
},
|
||||
secret::{ContainerCreateBody, CreateImageInfo},
|
||||
ClientVersion, Docker,
|
||||
};
|
||||
use bollard::{container::ListContainersOptions, models::ImageInspect, ClientVersion, Docker};
|
||||
|
||||
use futures::{future::join_all, StreamExt};
|
||||
use rustc_hash::FxHashMap;
|
||||
use futures::future::join_all;
|
||||
|
||||
use crate::{
|
||||
error,
|
||||
structs::{
|
||||
image::Image,
|
||||
update::{Update, UpdateInfo},
|
||||
},
|
||||
Context,
|
||||
};
|
||||
use crate::{error, structs::image::Image, Context};
|
||||
|
||||
fn create_docker_client(socket: Option<&str>) -> Docker {
|
||||
let client: Result<Docker, bollard::errors::Error> = match socket {
|
||||
@@ -59,8 +41,11 @@ pub async fn get_images_from_docker_daemon(
|
||||
ctx: &Context,
|
||||
references: &Option<Vec<String>>,
|
||||
) -> Vec<Image> {
|
||||
if ctx.config.socket.as_deref() == Some("none") {
|
||||
return vec![];
|
||||
}
|
||||
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
|
||||
let mut swarm_images = match client.list_services(None::<ListServicesOptions>).await {
|
||||
let mut swarm_images = match client.list_services::<String>(None).await {
|
||||
Ok(services) => services
|
||||
.iter()
|
||||
.filter_map(|service| match &service.spec {
|
||||
@@ -97,7 +82,7 @@ pub async fn get_images_from_docker_daemon(
|
||||
.collect()
|
||||
}
|
||||
None => {
|
||||
let images = match client.list_images(None::<ListImagesOptions>).await {
|
||||
let images = match client.list_images::<String>(None).await {
|
||||
Ok(images) => images,
|
||||
Err(e) => {
|
||||
error!("Failed to retrieve list of images available!\n{}", e)
|
||||
@@ -113,239 +98,37 @@ pub async fn get_images_from_docker_daemon(
|
||||
local_images
|
||||
}
|
||||
|
||||
pub async fn get_in_use_images(ctx: &Context) -> FxHashMap<String, Vec<String>> {
|
||||
pub async fn get_in_use_images(ctx: &Context) -> Vec<String> {
|
||||
if ctx.config.socket.as_deref() == Some("none") {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let client: Docker = create_docker_client(ctx.config.socket.as_deref());
|
||||
|
||||
let options = ListContainersOptionsBuilder::new().all(true).build();
|
||||
let containers = match client.list_containers(Some(options)).await {
|
||||
let containers = match client
|
||||
.list_containers::<String>(Some(ListContainersOptions {
|
||||
all: true,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(containers) => containers,
|
||||
Err(e) => {
|
||||
error!("Failed to retrieve list of containers available!\n{}", e)
|
||||
}
|
||||
};
|
||||
|
||||
let mut result: FxHashMap<String, Vec<String>> = FxHashMap::default();
|
||||
|
||||
containers
|
||||
.iter()
|
||||
.filter(|container| container.image.is_some())
|
||||
.for_each(|container| {
|
||||
let reference = match &container.image {
|
||||
Some(image) => {
|
||||
if image.contains(":") {
|
||||
image.clone()
|
||||
} else {
|
||||
format!("{image}:latest")
|
||||
}
|
||||
.filter_map(|container| match &container.image {
|
||||
Some(image) => Some({
|
||||
if image.contains(":") {
|
||||
image.clone()
|
||||
} else {
|
||||
format!("{image}:latest")
|
||||
}
|
||||
None => unreachable!(),
|
||||
};
|
||||
|
||||
let mut names: Vec<String> = container
|
||||
.names
|
||||
.as_ref()
|
||||
.map(|names| {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| name.trim_start_matches('/').to_owned())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
match result.get_mut(&reference) {
|
||||
Some(containers) => containers.append(&mut names),
|
||||
None => {
|
||||
let _ = result.insert(reference, names);
|
||||
}
|
||||
}
|
||||
});
|
||||
result.clone()
|
||||
}
|
||||
|
||||
/// Given a container name and the update information returned about the image it uses, tries to recreate it with a new image / latest version of the current image
|
||||
pub async fn upgrade_container(ctx: &Context, name: &str, update: &Update) -> Result<(), String> {
|
||||
let client: Docker = create_docker_client(ctx.config.socket.as_deref()); // TODO: Consider adding all these functions to a long lived struct with a shared client. We don't want to create a new client for every container updated.
|
||||
|
||||
// Create a few variables that will be used later on
|
||||
let new_name = format!("{name}__cup_temp"); // A new temporary name for the container. Instead of removing the old one straight away, we'll create a new one and if that succeeds we'll rename it.
|
||||
let new_image = match &update.result.info {
|
||||
// Find the new reference for the image, based on logic used in the web interface. This will be used to pull the new image
|
||||
UpdateInfo::Version(update_info) => format!(
|
||||
"{}:{}",
|
||||
update
|
||||
.reference
|
||||
.split_once(':')
|
||||
.expect("Reference contains `:`")
|
||||
.0,
|
||||
update_info.new_tag
|
||||
),
|
||||
UpdateInfo::Digest(_) => update.reference.clone(),
|
||||
UpdateInfo::None => unreachable!("Tried to update up-to-date image"),
|
||||
};
|
||||
ctx.logger.debug(format!("Upgrading {name}..."));
|
||||
|
||||
// Retrieve information about current container and construct required structs to create a new container afterwards
|
||||
let (create_options, create_config) = match client
|
||||
.inspect_container(name, None::<InspectContainerOptions>)
|
||||
.await
|
||||
{
|
||||
Ok(inspect) => {
|
||||
let create_options = {
|
||||
let mut options = CreateContainerOptionsBuilder::new();
|
||||
match inspect.name {
|
||||
Some(_) => options = options.name(&new_name),
|
||||
None => (), // Not sure if this is even reachable
|
||||
};
|
||||
match inspect.platform {
|
||||
Some(platform) => options = options.platform(&platform),
|
||||
None => (), // Same as above
|
||||
};
|
||||
options.build()
|
||||
};
|
||||
|
||||
let inspect_config = inspect.config.unwrap(); // For easier access later
|
||||
|
||||
let create_config = ContainerCreateBody {
|
||||
hostname: inspect_config.hostname,
|
||||
domainname: inspect_config.domainname,
|
||||
user: inspect_config.user,
|
||||
attach_stdin: inspect_config.attach_stdin,
|
||||
attach_stderr: inspect_config.attach_stderr,
|
||||
attach_stdout: inspect_config.attach_stdout,
|
||||
exposed_ports: inspect_config.exposed_ports,
|
||||
tty: inspect_config.tty,
|
||||
open_stdin: inspect_config.open_stdin,
|
||||
stdin_once: inspect_config.stdin_once,
|
||||
env: inspect_config.env,
|
||||
cmd: inspect_config.cmd,
|
||||
healthcheck: inspect_config.healthcheck,
|
||||
args_escaped: inspect_config.args_escaped,
|
||||
image: Some(new_image.clone()),
|
||||
volumes: inspect_config.volumes,
|
||||
working_dir: inspect_config.working_dir,
|
||||
entrypoint: inspect_config.entrypoint,
|
||||
network_disabled: inspect_config.network_disabled,
|
||||
mac_address: inspect_config.mac_address,
|
||||
on_build: inspect_config.on_build,
|
||||
labels: inspect_config.labels,
|
||||
stop_signal: inspect_config.stop_signal,
|
||||
stop_timeout: inspect_config.stop_timeout,
|
||||
shell: inspect_config.shell,
|
||||
host_config: inspect.host_config,
|
||||
// The commented out code below doesn't work because bollard sends gw_priority as a float and Docker expects an int. Tracking issue: https://github.com/fussybeaver/bollard/issues/537
|
||||
// networking_config: Some(bollard::secret::NetworkingConfig {
|
||||
// endpoints_config: inspect.network_settings.unwrap().networks,
|
||||
// }),
|
||||
networking_config: None,
|
||||
};
|
||||
(create_options, create_config)
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("Failed to inspect container {name}: {e}");
|
||||
ctx.logger.warn(&message);
|
||||
return Err(message)
|
||||
},
|
||||
};
|
||||
|
||||
// Stop the current container
|
||||
ctx.logger.debug(format!("Stopping {name}..."));
|
||||
match client
|
||||
.stop_container(name, None::<StopContainerOptions>)
|
||||
.await
|
||||
{
|
||||
Ok(()) => ctx.logger.debug(format!("Successfully stopped {name}")),
|
||||
Err(e) => {
|
||||
let message = format!("Failed to stop container {name}: {e}");
|
||||
ctx.logger.warn(&message);
|
||||
return Err(message)
|
||||
},
|
||||
};
|
||||
|
||||
// Don't let the naming fool you, we're pulling the new image here.
|
||||
ctx.logger.debug(format!("Pulling {new_image} for {name}..."));
|
||||
let create_image_options = CreateImageOptionsBuilder::new()
|
||||
.from_image(&new_image)
|
||||
.build();
|
||||
|
||||
client
|
||||
.create_image(Some(create_image_options), None, None) // TODO: credentials support
|
||||
.collect::<Vec<Result<CreateImageInfo, bollard::errors::Error>>>() // Not entirely sure this is the best way to handle a stream
|
||||
.await; // TODO: handle errors here
|
||||
ctx.logger.debug(format!("Successfully pulled new image for {name}"));
|
||||
|
||||
// Create the new container
|
||||
ctx.logger.debug(format!("Creating new container for {name}..."));
|
||||
match client
|
||||
.create_container(Some(create_options), create_config)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Let the user know if any warnings occured
|
||||
response
|
||||
.warnings
|
||||
.iter()
|
||||
.for_each(|warning| ctx.logger.warn(format!("[DAEMON]: {}", warning)));
|
||||
},
|
||||
Err(e) => {
|
||||
let message = format!("Failed to create new container for {name}: {e}");
|
||||
ctx.logger.warn(&message);
|
||||
return Err(message)
|
||||
},
|
||||
};
|
||||
|
||||
// Start the new container
|
||||
match client
|
||||
.start_container(&new_name, None::<StartContainerOptions>)
|
||||
.await
|
||||
{
|
||||
Ok(()) => ctx.logger.debug(format!("Successfully created new container for {name}")),
|
||||
Err(e) => {
|
||||
let message = format!("Failed to start new container for {name}: {e}");
|
||||
ctx.logger.warn(&message);
|
||||
return Err(message)
|
||||
},
|
||||
}
|
||||
|
||||
// Remove the old container
|
||||
ctx.logger.debug(format!("Removing old {name} container"));
|
||||
match client
|
||||
.remove_container(name, None::<RemoveContainerOptions>)
|
||||
.await
|
||||
{
|
||||
Ok(()) => ctx.logger.debug(format!("Successfully removed old {name} container")),
|
||||
Err(e) => {
|
||||
match e {
|
||||
bollard::errors::Error::DockerResponseServerError { status_code: 404, message } => {
|
||||
ctx.logger.warn(format!("Failed to remove container {name}, it was probably started with `--rm` and has been automatically cleaned up. Message from server: {message}"))
|
||||
},
|
||||
_ => {
|
||||
let message = format!("Failed to remove container {name}: {e}");
|
||||
ctx.logger.warn(&message);
|
||||
return Err(message)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rename the new container
|
||||
match client
|
||||
.rename_container(
|
||||
&new_name,
|
||||
RenameContainerOptions {
|
||||
name: name.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => (),
|
||||
Err(e) => {
|
||||
let message = format!("Failed to rename container {name}: {e}");
|
||||
ctx.logger.warn(&message);
|
||||
return Err(message)
|
||||
},
|
||||
}
|
||||
|
||||
ctx.logger.debug(format!("Successfully upgraded {name}!"));
|
||||
|
||||
Ok(())
|
||||
}),
|
||||
None => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
status::Status,
|
||||
update::{Update, UpdateInfo},
|
||||
},
|
||||
utils::{json::to_json, sort_update_vec::sort_update_vec},
|
||||
utils::{json::to_simple_json, sort_update_vec::sort_update_vec},
|
||||
};
|
||||
|
||||
pub fn print_updates(updates: &[Update], icons: &bool) {
|
||||
@@ -164,5 +164,5 @@ pub fn print_updates(updates: &[Update], icons: &bool) {
|
||||
}
|
||||
|
||||
pub fn print_raw_updates(updates: &[Update]) {
|
||||
println!("{}", to_json(updates));
|
||||
println!("{}", to_simple_json(updates));
|
||||
}
|
||||
|
||||
136
src/registry.rs
136
src/registry.rs
@@ -1,4 +1,4 @@
|
||||
use std::{cmp::Ordering, time::SystemTime};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
@@ -6,7 +6,10 @@ use crate::{
|
||||
config::UpdateType,
|
||||
error,
|
||||
http::Client,
|
||||
structs::{image::Image, version::Version},
|
||||
structs::{
|
||||
image::{DigestInfo, Image, VersionInfo},
|
||||
version::Version,
|
||||
},
|
||||
utils::{
|
||||
link::parse_link,
|
||||
request::{
|
||||
@@ -41,11 +44,11 @@ pub async fn check_auth(registry: &str, ctx: &Context, client: &Client) -> Optio
|
||||
}
|
||||
|
||||
pub async fn get_latest_digest(
|
||||
image: &mut Image,
|
||||
image: &Image,
|
||||
token: Option<&str>,
|
||||
ctx: &Context,
|
||||
client: &Client,
|
||||
) -> () {
|
||||
) -> Image {
|
||||
ctx.logger
|
||||
.debug(format!("Checking for digest update to {}", image.reference));
|
||||
let start = SystemTime::now();
|
||||
@@ -55,7 +58,7 @@ pub async fn get_latest_digest(
|
||||
protocol, &image.parts.registry, &image.parts.repository, &image.parts.tag
|
||||
);
|
||||
let authorization = to_bearer_string(&token);
|
||||
let headers = [("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json")), ("Authorization", authorization.as_deref())];
|
||||
let headers = [("Accept", Some("application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json")), ("Authorization", authorization.as_deref())];
|
||||
|
||||
let response = client.head(&url, &headers).await;
|
||||
let time = start.elapsed().unwrap().as_millis() as u32;
|
||||
@@ -66,17 +69,29 @@ pub async fn get_latest_digest(
|
||||
match response {
|
||||
Ok(res) => match res.headers().get("docker-content-digest") {
|
||||
Some(digest) => {
|
||||
image.update_info.remote_digest = Some(digest.to_str().unwrap().to_owned());
|
||||
let local_digests = match &image.digest_info {
|
||||
Some(data) => data.local_digests.clone(),
|
||||
None => return image.clone(),
|
||||
};
|
||||
Image {
|
||||
digest_info: Some(DigestInfo {
|
||||
remote_digest: Some(digest.to_str().unwrap().to_string()),
|
||||
local_digests,
|
||||
}),
|
||||
time_ms: image.time_ms + time,
|
||||
..image.clone()
|
||||
}
|
||||
}
|
||||
None => error!(
|
||||
"Server returned invalid response! No docker-content-digest!\n{:#?}",
|
||||
res
|
||||
),
|
||||
},
|
||||
Err(error) => {
|
||||
image.error = Some(error);
|
||||
image.time_ms = image.time_ms + elapsed(start)
|
||||
}
|
||||
Err(error) => Image {
|
||||
error: Some(error),
|
||||
time_ms: image.time_ms + time,
|
||||
..image.clone()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +117,12 @@ pub async fn get_token(
|
||||
}
|
||||
|
||||
pub async fn get_latest_tag(
|
||||
image: &mut Image,
|
||||
image: &Image,
|
||||
base: &Version,
|
||||
token: Option<&str>,
|
||||
ctx: &Context,
|
||||
client: &Client,
|
||||
) -> () {
|
||||
) -> Image {
|
||||
ctx.logger
|
||||
.debug(format!("Checking for tag update to {}", image.reference));
|
||||
let start = now();
|
||||
@@ -135,7 +150,7 @@ pub async fn get_latest_tag(
|
||||
&next_url.unwrap(),
|
||||
&headers,
|
||||
base,
|
||||
&image.reference,
|
||||
&image.version_info.as_ref().unwrap().format_str,
|
||||
ctx,
|
||||
client,
|
||||
)
|
||||
@@ -143,22 +158,17 @@ pub async fn get_latest_tag(
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(message) => {
|
||||
image.error = Some(message);
|
||||
image.time_ms += elapsed(start);
|
||||
return;
|
||||
return Image {
|
||||
error: Some(message),
|
||||
time_ms: image.time_ms + elapsed(start),
|
||||
..image.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
tags.extend_from_slice(&new_tags);
|
||||
next_url = next;
|
||||
}
|
||||
let tag = tags.iter().reduce(|a, b| match a.partial_cmp(b) {
|
||||
Some(ordering) => match ordering {
|
||||
Ordering::Greater => a,
|
||||
Ordering::Equal => b,
|
||||
Ordering::Less => b,
|
||||
},
|
||||
None => unreachable!(),
|
||||
});
|
||||
let tag = tags.iter().max();
|
||||
ctx.logger.debug(format!(
|
||||
"Checked for tag update to {} in {}ms",
|
||||
image.reference,
|
||||
@@ -166,17 +176,32 @@ pub async fn get_latest_tag(
|
||||
));
|
||||
match tag {
|
||||
Some(t) => {
|
||||
if t == base && !image.info.local_digests.is_empty() {
|
||||
if t == base && image.digest_info.is_some() {
|
||||
// Tags are equal so we'll compare digests
|
||||
ctx.logger.debug(format!(
|
||||
"Tags for {} are equal, comparing digests.",
|
||||
image.reference
|
||||
));
|
||||
image.time_ms += elapsed(start);
|
||||
get_latest_digest(image, token, ctx, client).await
|
||||
get_latest_digest(
|
||||
&Image {
|
||||
version_info: None, // Overwrite previous version info, since it isn't useful anymore (equal tags means up to date and an image is truly up to date when its digests are up to date, and we'll be checking those anyway)
|
||||
time_ms: image.time_ms + elapsed(start),
|
||||
..image.clone()
|
||||
},
|
||||
token,
|
||||
ctx,
|
||||
client,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
image.update_info.latest_version = Some(t.clone());
|
||||
image.time_ms += elapsed(start);
|
||||
Image {
|
||||
version_info: Some(VersionInfo {
|
||||
latest_remote_tag: Some(t.clone()),
|
||||
..image.version_info.as_ref().unwrap().clone()
|
||||
}),
|
||||
time_ms: image.time_ms + elapsed(start),
|
||||
..image.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
None => error!(
|
||||
@@ -190,12 +215,12 @@ pub async fn get_extra_tags(
|
||||
url: &str,
|
||||
headers: &[(&str, Option<&str>)],
|
||||
base: &Version,
|
||||
reference: &str,
|
||||
format_str: &str,
|
||||
ctx: &Context,
|
||||
client: &Client,
|
||||
) -> Result<(Vec<Version>, Option<String>), String> {
|
||||
let response = client.get(url, headers, false).await;
|
||||
let base_type = base.r#type();
|
||||
|
||||
match response {
|
||||
Ok(res) => {
|
||||
let next_url = res
|
||||
@@ -207,38 +232,25 @@ pub async fn get_extra_tags(
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|tag| Version::from(tag.as_str().unwrap(), base_type.as_ref()))
|
||||
.filter(|tag| tag.r#type() == base_type)
|
||||
.filter(|tag| tag.partial_cmp(base).is_some())
|
||||
.filter_map(|tag| {
|
||||
match ctx
|
||||
.config
|
||||
.images
|
||||
.iter()
|
||||
.filter(|&(i, _)| reference.starts_with(i))
|
||||
.sorted_by(|(a, _), (b, _)| a.len().cmp(&b.len()))
|
||||
.next()
|
||||
.map(|(_, cfg)| &cfg.ignore)
|
||||
.unwrap_or(&UpdateType::None)
|
||||
{
|
||||
// TODO: Please don't ship it like this
|
||||
UpdateType::None => Some(tag),
|
||||
UpdateType::Major => Some(tag).filter(|tag| {
|
||||
base.as_standard().unwrap().major == tag.as_standard().unwrap().major
|
||||
}),
|
||||
UpdateType::Minor => Some(tag).filter(|tag| {
|
||||
base.as_standard().unwrap().major == tag.as_standard().unwrap().major
|
||||
&& base.as_standard().unwrap().minor
|
||||
== tag.as_standard().unwrap().minor
|
||||
}),
|
||||
UpdateType::Patch => Some(tag).filter(|tag| {
|
||||
base.as_standard().unwrap().major == tag.as_standard().unwrap().major
|
||||
&& base.as_standard().unwrap().minor
|
||||
== tag.as_standard().unwrap().minor
|
||||
&& base.as_standard().unwrap().patch
|
||||
== tag.as_standard().unwrap().patch
|
||||
}),
|
||||
.filter_map(|tag| Version::from_tag(tag.as_str().unwrap()))
|
||||
.filter(|(tag, format_string)| match (base.minor, tag.minor) {
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
matches!((base.patch, tag.patch), (Some(_), Some(_)) | (None, None))
|
||||
&& format_str == *format_string
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.filter_map(|(tag, _)| match ctx.config.ignore_update_type {
|
||||
UpdateType::None => Some(tag),
|
||||
UpdateType::Major => Some(tag).filter(|tag| base.major == tag.major),
|
||||
UpdateType::Minor => {
|
||||
Some(tag).filter(|tag| base.major == tag.major && base.minor == tag.minor)
|
||||
}
|
||||
UpdateType::Patch => Some(tag).filter(|tag| {
|
||||
base.major == tag.major
|
||||
&& base.minor == tag.minor
|
||||
&& base.patch == tag.patch
|
||||
}),
|
||||
})
|
||||
.dedup()
|
||||
.collect();
|
||||
|
||||
153
src/server.rs
153
src/server.rs
@@ -1,20 +1,19 @@
|
||||
use std::{env, sync::Arc, time::SystemTime};
|
||||
use std::{env, sync::Arc};
|
||||
|
||||
use chrono::Local;
|
||||
use chrono_tz::Tz;
|
||||
use liquid::{object, Object, ValueView};
|
||||
use rustc_hash::FxHashMap;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_cron_scheduler::{Job, JobScheduler};
|
||||
use xitca_web::{
|
||||
body::ResponseBody,
|
||||
bytes::Bytes,
|
||||
error::Error,
|
||||
handler::{handler_service, json::LazyJson, path::PathRef, state::StateRef},
|
||||
handler::{handler_service, path::PathRef, state::StateRef},
|
||||
http::{StatusCode, WebResponse},
|
||||
route::{get, post},
|
||||
route::get,
|
||||
service::Service,
|
||||
App, WebContext,
|
||||
};
|
||||
@@ -22,11 +21,10 @@ use xitca_web::{
|
||||
use crate::{
|
||||
check::get_updates,
|
||||
config::Theme,
|
||||
docker::upgrade_container,
|
||||
error,
|
||||
structs::update::Update,
|
||||
utils::{
|
||||
json::to_json,
|
||||
json::{to_full_json, to_simple_json},
|
||||
sort_update_vec::sort_update_vec,
|
||||
time::{elapsed, now},
|
||||
},
|
||||
@@ -40,10 +38,6 @@ const FAVICON_ICO: Bytes = Bytes::from_static(include_bytes!("static/favicon.ico
|
||||
const FAVICON_SVG: Bytes = Bytes::from_static(include_bytes!("static/favicon.svg"));
|
||||
const APPLE_TOUCH_ICON: Bytes = Bytes::from_static(include_bytes!("static/apple-touch-icon.png"));
|
||||
|
||||
const SUCCESS_STATUS: &str = r#"{"success":true}"#; // Store this to avoid recomputation
|
||||
const UPGRADE_INTERNAL_SERVER_ERROR: &str =
|
||||
r#"{"success":"false","message":"Internal server error. Please view logs for details"}"#;
|
||||
|
||||
const SORT_ORDER: [&str; 8] = [
|
||||
"monitored_images",
|
||||
"updates_available",
|
||||
@@ -59,7 +53,7 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
|
||||
ctx.logger.info("Starting server, please wait...");
|
||||
let data = ServerData::new(ctx).await;
|
||||
let scheduler = JobScheduler::new().await.unwrap();
|
||||
let data = Arc::new(RwLock::new(data));
|
||||
let data = Arc::new(Mutex::new(data));
|
||||
let data_copy = data.clone();
|
||||
let tz = env::var("TZ")
|
||||
.map(|tz| tz.parse().unwrap_or(Tz::UTC))
|
||||
@@ -73,7 +67,7 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
|
||||
move |_uuid, _lock| {
|
||||
let data_copy = data_copy.clone();
|
||||
Box::pin(async move {
|
||||
data_copy.write().await.refresh().await;
|
||||
data_copy.lock().await.refresh().await;
|
||||
})
|
||||
},
|
||||
) {
|
||||
@@ -97,11 +91,10 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
|
||||
ctx.logger.info("Ready to start!");
|
||||
let mut app_builder = App::new()
|
||||
.with_state(data)
|
||||
.at("/api/v3/json", get(handler_service(json)))
|
||||
.at("/api/v3/refresh", get(handler_service(refresh_v3)))
|
||||
.at("/api/v4/json", get(handler_service(json)))
|
||||
.at("/api/v4/refresh", get(handler_service(refresh_v4)))
|
||||
.at("/api/v4/upgrade", post(handler_service(upgrade)));
|
||||
.at("/api/v2/json", get(handler_service(api_simple)))
|
||||
.at("/api/v3/json", get(handler_service(api_full)))
|
||||
.at("/api/v2/refresh", get(handler_service(refresh)))
|
||||
.at("/api/v3/refresh", get(handler_service(refresh)));
|
||||
if !ctx.config.agent {
|
||||
app_builder = app_builder
|
||||
.at("/", get(handler_service(_static)))
|
||||
@@ -119,17 +112,17 @@ pub async fn serve(port: &u16, ctx: &Context) -> std::io::Result<()> {
|
||||
.wait()
|
||||
}
|
||||
|
||||
async fn _static(data: StateRef<'_, Arc<RwLock<ServerData>>>, path: PathRef<'_>) -> WebResponse {
|
||||
async fn _static(data: StateRef<'_, Arc<Mutex<ServerData>>>, path: PathRef<'_>) -> WebResponse {
|
||||
match path.0 {
|
||||
"/" => WebResponse::builder()
|
||||
.header("Content-Type", "text/html")
|
||||
.body(ResponseBody::from(data.read().await.template.clone()))
|
||||
.body(ResponseBody::from(data.lock().await.template.clone()))
|
||||
.unwrap(),
|
||||
"/assets/index.js" => WebResponse::builder()
|
||||
.header("Content-Type", "text/javascript")
|
||||
.body(ResponseBody::from(JS.replace(
|
||||
"=\"neutral\"",
|
||||
&format!("=\"{}\"", data.read().await.theme),
|
||||
&format!("=\"{}\"", data.lock().await.theme),
|
||||
)))
|
||||
.unwrap(),
|
||||
"/assets/index.css" => WebResponse::builder()
|
||||
@@ -155,60 +148,34 @@ async fn _static(data: StateRef<'_, Arc<RwLock<ServerData>>>, path: PathRef<'_>)
|
||||
}
|
||||
}
|
||||
|
||||
async fn json(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
|
||||
async fn api_simple(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
|
||||
WebResponse::builder()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(ResponseBody::from(
|
||||
data.read().await.json.clone().to_string(),
|
||||
data.lock().await.simple_json.clone().to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn refresh_v3(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
|
||||
data.write().await.refresh().await;
|
||||
async fn api_full(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
|
||||
WebResponse::builder()
|
||||
.header("Content-Type", "application/json")
|
||||
.body(ResponseBody::from(
|
||||
data.lock().await.full_json.clone().to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn refresh(data: StateRef<'_, Arc<Mutex<ServerData>>>) -> WebResponse {
|
||||
data.lock().await.refresh().await;
|
||||
WebResponse::new(ResponseBody::from("OK"))
|
||||
}
|
||||
|
||||
async fn refresh_v4(data: StateRef<'_, Arc<RwLock<ServerData>>>) -> WebResponse {
|
||||
data.write().await.refresh().await;
|
||||
WebResponse::new(ResponseBody::from(SUCCESS_STATUS))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpgradeRequest {
|
||||
name: String, // Container name to be upgraded
|
||||
}
|
||||
|
||||
async fn upgrade(
|
||||
data: StateRef<'_, Arc<RwLock<ServerData>>>,
|
||||
body: LazyJson<UpgradeRequest>,
|
||||
) -> WebResponse {
|
||||
let data = data.read().await;
|
||||
let UpgradeRequest { name } = match body.deserialize::<UpgradeRequest>() {
|
||||
Ok(ur) => ur,
|
||||
Err(e) => {
|
||||
return WebResponse::builder().status(StatusCode::BAD_REQUEST).body(ResponseBody::from(serde_json::json!({"success": "false", "message": format!("Invalid JSON payload: {e}")}).to_string())).unwrap()
|
||||
}
|
||||
};
|
||||
match data.raw_updates.iter().find(|update| {
|
||||
update.used_by.contains(&name)
|
||||
&& update.status.to_option_bool().is_some_and(|status| status)
|
||||
}) {
|
||||
Some(update) => match upgrade_container(&data.ctx, &name, update).await {
|
||||
Ok(()) => WebResponse::new(ResponseBody::from(SUCCESS_STATUS)),
|
||||
Err(_) => WebResponse::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(ResponseBody::from(UPGRADE_INTERNAL_SERVER_ERROR))
|
||||
.unwrap(),
|
||||
},
|
||||
None => WebResponse::builder().status(StatusCode::BAD_REQUEST).body(ResponseBody::from(serde_json::json!({"success": "false", "message": format!("Container `{name}` does not exist or has no updates")}).to_string())).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerData {
|
||||
template: String,
|
||||
raw_updates: Vec<Update>,
|
||||
json: Value,
|
||||
simple_json: Value,
|
||||
full_json: Value,
|
||||
ctx: Context,
|
||||
theme: &'static str,
|
||||
}
|
||||
@@ -218,12 +185,10 @@ impl ServerData {
|
||||
let mut s = Self {
|
||||
ctx: ctx.clone(),
|
||||
template: String::new(),
|
||||
json: Value::Null,
|
||||
simple_json: Value::Null,
|
||||
full_json: Value::Null,
|
||||
raw_updates: Vec::new(),
|
||||
theme: match ctx.config.theme {
|
||||
Theme::Default => "neutral",
|
||||
Theme::Blue => "gray",
|
||||
},
|
||||
theme: "neutral",
|
||||
};
|
||||
s.refresh().await;
|
||||
s
|
||||
@@ -245,13 +210,19 @@ impl ServerData {
|
||||
.unwrap()
|
||||
.parse(HTML)
|
||||
.unwrap();
|
||||
self.json = to_json(&self.raw_updates);
|
||||
self.simple_json = to_simple_json(&self.raw_updates);
|
||||
self.full_json = to_full_json(&self.raw_updates);
|
||||
let last_updated = Local::now();
|
||||
self.json["last_updated"] = last_updated
|
||||
self.simple_json["last_updated"] = last_updated
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
.to_string()
|
||||
.into();
|
||||
let mut metrics = self.json["metrics"]
|
||||
self.full_json["last_updated"] = self.simple_json["last_updated"].clone();
|
||||
self.theme = match &self.ctx.config.theme {
|
||||
Theme::Default => "neutral",
|
||||
Theme::Blue => "gray",
|
||||
};
|
||||
let mut metrics = self.simple_json["metrics"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
@@ -301,11 +272,17 @@ where
|
||||
let method = request.method().to_string();
|
||||
let url = request.uri().to_string();
|
||||
|
||||
match (method.as_str(), url.as_str()) {
|
||||
("POST", "/api/v4/upgrade") => continue_request(ctx, next, &method, &url, start).await,
|
||||
("GET", "/api/v4/upgrade") | ("POST", _) => return_405(&method, &url, start).await,
|
||||
("GET", _) => continue_request(ctx, next, &method, &url, start).await,
|
||||
(_, _) => return_405(&method, &url, start).await,
|
||||
if &method != "GET" {
|
||||
// We only allow GET requests
|
||||
|
||||
log(&method, &url, 405, elapsed(start));
|
||||
Err(Error::from(StatusCode::METHOD_NOT_ALLOWED))
|
||||
} else {
|
||||
let res = next.call(ctx).await?;
|
||||
let status = res.status().as_u16();
|
||||
|
||||
log(&method, &url, status, elapsed(start));
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,29 +299,3 @@ fn log(method: &str, url: &str, status: u16, time: u32) {
|
||||
method, url, color, status, time
|
||||
)
|
||||
}
|
||||
|
||||
async fn continue_request<S, C, B>(
|
||||
ctx: WebContext<'_, C, B>,
|
||||
next: &S,
|
||||
method: &str,
|
||||
url: &str,
|
||||
start: SystemTime,
|
||||
) -> Result<WebResponse, Error<C>>
|
||||
where
|
||||
S: for<'r> Service<WebContext<'r, C, B>, Response = WebResponse, Error = Error<C>>,
|
||||
{
|
||||
let res = next.call(ctx).await?;
|
||||
let status = res.status().as_u16();
|
||||
|
||||
log(&method, &url, status, elapsed(start));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn return_405<C>(
|
||||
method: &str,
|
||||
url: &str,
|
||||
start: SystemTime,
|
||||
) -> Result<WebResponse, Error<C>> {
|
||||
log(&method, &url, 405, elapsed(start));
|
||||
Err(Error::from(StatusCode::METHOD_NOT_ALLOWED))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::{
|
||||
error,
|
||||
http::Client,
|
||||
registry::{get_latest_digest, get_latest_tag},
|
||||
structs::{standard_version::StandardVersionPart, status::Status, update::Update, version::Version},
|
||||
structs::{status::Status, version::Version},
|
||||
utils::reference::split,
|
||||
Context,
|
||||
};
|
||||
@@ -10,35 +10,35 @@ use crate::{
|
||||
use super::{
|
||||
inspectdata::InspectData,
|
||||
parts::Parts,
|
||||
update::{DigestUpdateInfo, UpdateResult, VersionUpdateInfo},
|
||||
update::{DigestUpdateInfo, Update, UpdateInfo, UpdateResult, VersionUpdateInfo},
|
||||
};
|
||||
|
||||
/// Any local information about the image
|
||||
#[derive(Clone, Default)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct Info {
|
||||
pub struct DigestInfo {
|
||||
pub local_digests: Vec<String>,
|
||||
pub version: Version,
|
||||
pub url: Option<String>,
|
||||
pub used_by: Vec<String>,
|
||||
pub remote_digest: Option<String>,
|
||||
}
|
||||
|
||||
/// Any new information obtained about the image
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdateInfo {
|
||||
pub remote_digest: Option<String>,
|
||||
pub latest_version: Option<Version>
|
||||
#[derive(Clone, PartialEq)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct VersionInfo {
|
||||
pub current_tag: Version,
|
||||
pub latest_remote_tag: Option<Version>,
|
||||
pub format_str: String,
|
||||
}
|
||||
|
||||
/// 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, Default)]
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
#[cfg_attr(test, derive(Debug))]
|
||||
pub struct Image {
|
||||
pub reference: String,
|
||||
pub parts: Parts,
|
||||
pub info: Info,
|
||||
pub update_info: UpdateInfo,
|
||||
pub url: Option<String>,
|
||||
pub digest_info: Option<DigestInfo>,
|
||||
pub version_info: Option<VersionInfo>,
|
||||
pub in_use: bool,
|
||||
pub error: Option<String>,
|
||||
pub time_ms: u32,
|
||||
}
|
||||
@@ -54,7 +54,7 @@ impl Image {
|
||||
return None; // As far as I know, references that contain @ are either manually pulled by the user or automatically created because of swarm. In the first case AFAICT we can't know what tag was originally pulled, so we'd have to make assumptions and I've decided to remove this. The other case is already handled seperately, so this also ensures images aren't displayed twice, once with and once without a digest.
|
||||
};
|
||||
let (registry, repository, tag) = split(&reference);
|
||||
let version_tag = Version::from(&tag, ctx.config.images.get(&reference).map(|cfg| &cfg.tag_type));
|
||||
let version_tag = Version::from_tag(&tag);
|
||||
let local_digests = digests
|
||||
.iter()
|
||||
.filter_map(
|
||||
@@ -77,7 +77,16 @@ impl Image {
|
||||
repository,
|
||||
tag,
|
||||
},
|
||||
info: Info { local_digests, version: version_tag, url: image.url(), used_by: Vec::new() },
|
||||
url: image.url(),
|
||||
digest_info: Some(DigestInfo {
|
||||
local_digests,
|
||||
remote_digest: None,
|
||||
}),
|
||||
version_info: version_tag.map(|(vtag, format_str)| VersionInfo {
|
||||
current_tag: vtag,
|
||||
format_str,
|
||||
latest_remote_tag: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
@@ -86,24 +95,28 @@ impl Image {
|
||||
}
|
||||
|
||||
/// Creates and populates the fields of an Image object based on a reference. If the tag is not recognized as a version string, exits the program with an error.
|
||||
pub fn from_reference(reference: &str, ctx: &Context) -> Self {
|
||||
pub fn from_reference(reference: &str) -> Self {
|
||||
let (registry, repository, tag) = split(reference);
|
||||
let version_tag = Version::from(&tag, ctx.config.images.get(reference).map(|cfg| &cfg.tag_type));
|
||||
let version_tag = Version::from_tag(&tag);
|
||||
match version_tag {
|
||||
Version::Unknown => error!(
|
||||
"Image {} is not available locally and does not have a recognizable tag format!",
|
||||
reference
|
||||
),
|
||||
v => Self {
|
||||
Some((version, format_str)) => Self {
|
||||
reference: reference.to_string(),
|
||||
parts: Parts {
|
||||
registry,
|
||||
repository,
|
||||
tag,
|
||||
},
|
||||
info: Info { local_digests: Vec::new(), version: v, url: None, used_by: Vec::new() },
|
||||
version_info: Some(VersionInfo {
|
||||
current_tag: version,
|
||||
format_str,
|
||||
latest_remote_tag: None,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
None => error!(
|
||||
"Image {} is not available locally and does not have a recognizable tag format!",
|
||||
reference
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,18 +124,25 @@ impl Image {
|
||||
if self.error.is_some() {
|
||||
Status::Unknown(self.error.clone().unwrap())
|
||||
} else {
|
||||
match self.update_info.latest_version {
|
||||
Some(latest_version) => latest_version.to_status(self.info.version),
|
||||
None => match self.update_info.remote_digest {
|
||||
Some(remote_digest) => {
|
||||
if self.info.local_digests.contains(&remote_digest) {
|
||||
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?
|
||||
}
|
||||
}
|
||||
None => unreachable!(), // I hope?
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,14 +158,20 @@ impl Image {
|
||||
Update {
|
||||
reference: self.reference.clone(),
|
||||
parts: self.parts.clone(),
|
||||
url: self.info.url.clone(),
|
||||
url: self.url.clone(),
|
||||
result: UpdateResult {
|
||||
has_update: has_update.to_option_bool(),
|
||||
info: match has_update {
|
||||
Status::Unknown(_) => crate::structs::update::UpdateInfo::None,
|
||||
Status::Unknown(_) => UpdateInfo::None,
|
||||
_ => match update_type {
|
||||
"version" => {
|
||||
let update_info = &self.update_info.latest_version.unwrap().as_standard().unwrap();
|
||||
let (new_tag, format_str) = match &self.version_info {
|
||||
Some(data) => (
|
||||
data.latest_remote_tag.clone().unwrap(),
|
||||
data.format_str.clone(),
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
UpdateInfo::Version(VersionUpdateInfo {
|
||||
version_update_type: match has_update {
|
||||
@@ -155,12 +181,12 @@ impl Image {
|
||||
_ => unreachable!(),
|
||||
}
|
||||
.to_string(),
|
||||
new_tag: update_info.format_str
|
||||
.replacen("{}", &update_info.major.to_string(), 1)
|
||||
.replacen("{}", &update_info.minor.unwrap_or_default().to_string(), 1)
|
||||
.replacen("{}", &update_info.patch.unwrap_or_default().to_string(), 1),
|
||||
new_tag: format_str
|
||||
.replacen("{}", &new_tag.major.to_string(), 1)
|
||||
.replacen("{}", &new_tag.minor.unwrap_or(0).to_string(), 1)
|
||||
.replacen("{}", &new_tag.patch.unwrap_or(0).to_string(), 1),
|
||||
// Throwing these in, because they're useful for the CLI output, however we won't (de)serialize them
|
||||
current_version: self.info.version.as_standard().unwrap().to_string()
|
||||
current_version: self
|
||||
.version_info
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
@@ -196,7 +222,7 @@ impl Image {
|
||||
},
|
||||
time: self.time_ms,
|
||||
server: None,
|
||||
used_by: self.used_by.clone(),
|
||||
in_use: self.in_use,
|
||||
status: has_update,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,4 +4,3 @@ pub mod parts;
|
||||
pub mod status;
|
||||
pub mod update;
|
||||
pub mod version;
|
||||
pub mod standard_version;
|
||||
@@ -1,180 +0,0 @@
|
||||
use std::{cmp::Ordering, fmt::Display};
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug, Default)] // Default is so I can avoid constructing a struct every time I want to use a version number of 0 as a default.
|
||||
pub struct StandardVersionPart {
|
||||
value: u32,
|
||||
length: u8, // If the value is prefixed by zeroes, the total length, otherwise 0
|
||||
}
|
||||
|
||||
impl StandardVersionPart {
|
||||
fn from_split(split: &str) -> Self {
|
||||
if split.len() == 1 && split == "0" {
|
||||
Self::default()
|
||||
} else {
|
||||
Self {
|
||||
value: split.parse().expect("Expected number to be less than 2^32"), // Unwrapping is safe, because we've verified that the string consists of digits and we don't care about supporting big numbers.
|
||||
length: {
|
||||
if split.starts_with('0') {
|
||||
split.len() as u8 // We're casting the zeroes to u8, because no sane person uses more than 255 zeroes as a version prefix. Oh wait, tags can't even be that long
|
||||
} else {
|
||||
0
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for StandardVersionPart {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
if self.length == other.length {
|
||||
self.value.partial_cmp(&other.value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for StandardVersionPart {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!(
|
||||
"{:0<zeroes$}",
|
||||
self.value,
|
||||
zeroes = self.length as usize
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a semver-like version.
|
||||
/// While not conforming to the SemVer standard, but was designed to handle common versioning schemes across a wide range of Docker images.
|
||||
/// Minor and patch versions are considered optional.
|
||||
/// Matching happens with a regex.
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct StandardVersion {
|
||||
pub major: StandardVersionPart,
|
||||
pub minor: Option<StandardVersionPart>,
|
||||
pub patch: Option<StandardVersionPart>,
|
||||
pub format_str: String, // The tag with {} in the place the version was matched.
|
||||
}
|
||||
|
||||
impl StandardVersion {
|
||||
/// Tries to extract a semver-like version from a tag.
|
||||
/// Returns a Result<StandardVersion, ()> indicating whether parsing succeeded
|
||||
pub fn from_tag(tag: &str) -> Result<Self, ()> {
|
||||
/// 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.
|
||||
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?P<major>[0-9]+)(?:\.(?P<minor>[0-9]*))?(?:\.(?P<patch>[0-9]*))?")
|
||||
.unwrap()
|
||||
});
|
||||
let mut captures = VERSION_REGEX.captures_iter(tag);
|
||||
// And now... terrible best match selection for everyone! Actually, it's probably not that terrible. I don't know.
|
||||
match captures.next() {
|
||||
Some(mut best_match) => {
|
||||
let mut max_matches: u8 = 0; // Why does Rust not have `u2`s?
|
||||
for capture in captures {
|
||||
let count = capture.iter().filter_map(|c| c).count() as u8;
|
||||
if count > max_matches {
|
||||
max_matches = count;
|
||||
best_match = capture;
|
||||
}
|
||||
}
|
||||
|
||||
let start_pos;
|
||||
let mut end_pos;
|
||||
let major: StandardVersionPart = match best_match.name("major") {
|
||||
Some(major) => {
|
||||
start_pos = major.start();
|
||||
end_pos = major.end();
|
||||
StandardVersionPart::from_split(major.as_str())
|
||||
}
|
||||
None => return Err(()),
|
||||
};
|
||||
let minor: Option<StandardVersionPart> = best_match.name("minor").map(|minor| {
|
||||
end_pos = minor.end();
|
||||
StandardVersionPart::from_split(minor.as_str())
|
||||
});
|
||||
let patch: Option<StandardVersionPart> = best_match.name("patch").map(|patch| {
|
||||
end_pos = patch.end();
|
||||
StandardVersionPart::from_split(patch.as_str())
|
||||
});
|
||||
let mut format_str = tag.to_string();
|
||||
format_str.replace_range(start_pos..end_pos, "{}");
|
||||
Ok(Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
format_str,
|
||||
})
|
||||
}
|
||||
None => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for StandardVersion {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
if self.format_str != other.format_str {
|
||||
None
|
||||
} else {
|
||||
match self.major.partial_cmp(&other.major) {
|
||||
Some(ordering) => match ordering {
|
||||
Ordering::Equal => match self.minor.partial_cmp(&other.minor) {
|
||||
Some(ordering) => match ordering {
|
||||
Ordering::Equal => self.patch.partial_cmp(&other.patch),
|
||||
_ => Some(ordering),
|
||||
},
|
||||
None => None,
|
||||
},
|
||||
_ => Some(ordering),
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[rustfmt::skip]
|
||||
fn standard_version() {
|
||||
assert_eq!(StandardVersion::from_tag("5.3.2"), Ok(StandardVersion { major: StandardVersionPart { value: 5, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }) , format_str: String::from("{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("14"), Ok(StandardVersion { major: StandardVersionPart { value: 14, length: 0 }, minor: None, patch: None , format_str: String::from("{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("v0.107.53"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 107, length: 0 }), patch: Some(StandardVersionPart { value: 53, length: 0 }) , format_str: String::from("v{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("12-alpine"), Ok(StandardVersion { major: StandardVersionPart { value: 12, length: 0 }, minor: None, patch: None , format_str: String::from("{}-alpine") }));
|
||||
assert_eq!(StandardVersion::from_tag("0.9.5-nginx"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 9, length: 0 }), patch: Some(StandardVersionPart { value: 5, length: 0 }) , format_str: String::from("{}-nginx") }));
|
||||
assert_eq!(StandardVersion::from_tag("v27.0"), Ok(StandardVersion { major: StandardVersionPart { value: 27, length: 0 }, minor: Some(StandardVersionPart { value: 0, length: 0 }), patch: None , format_str: String::from("v{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("16.1"), Ok(StandardVersion { major: StandardVersionPart { value: 16, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: None , format_str: String::from("{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("version-1.5.6"), Ok(StandardVersion { major: StandardVersionPart { value: 1, length: 0 }, minor: Some(StandardVersionPart { value: 5, length: 0 }), patch: Some(StandardVersionPart { value: 6, length: 0 }) , format_str: String::from("version-{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("15.4-alpine"), Ok(StandardVersion { major: StandardVersionPart { value: 15, length: 0 }, minor: Some(StandardVersionPart { value: 4, length: 0 }), patch: None , format_str: String::from("{}-alpine") }));
|
||||
assert_eq!(StandardVersion::from_tag("pg14-v0.2.0"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 2, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("pg14-v{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("18-jammy-full.s6-v0.88.0"), Ok(StandardVersion { major: StandardVersionPart { value: 0, length: 0 }, minor: Some(StandardVersionPart { value: 88, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("18-jammy-full.s6-v{}") }));
|
||||
assert_eq!(StandardVersion::from_tag("fpm-2.1.0-prod"), Ok(StandardVersion { major: StandardVersionPart { value: 2, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: Some(StandardVersionPart { value: 0, length: 0 }) , format_str: String::from("fpm-{}-prod") }));
|
||||
assert_eq!(StandardVersion::from_tag("7.3.3.50"), Ok(StandardVersion { major: StandardVersionPart { value: 7, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 3, length: 0 }) , format_str: String::from("{}.50") }));
|
||||
assert_eq!(StandardVersion::from_tag("1.21.11-0"), Ok(StandardVersion { major: StandardVersionPart { value: 1, length: 0 }, minor: Some(StandardVersionPart { value: 21, length: 0 }), patch: Some(StandardVersionPart { value: 11, length: 0 }) , format_str: String::from("{}-0") }));
|
||||
assert_eq!(StandardVersion::from_tag("4.1.2.1-full"), Ok(StandardVersion { major: StandardVersionPart { value: 4, length: 0 }, minor: Some(StandardVersionPart { value: 1, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }) , format_str: String::from("{}.1-full") }));
|
||||
assert_eq!(StandardVersion::from_tag("v4.0.3-ls215"), Ok(StandardVersion { major: StandardVersionPart { value: 4, length: 0 }, minor: Some(StandardVersionPart { value: 0, length: 0 }), patch: Some(StandardVersionPart { value: 3, length: 0 }) , format_str: String::from("v{}-ls215") }));
|
||||
assert_eq!(StandardVersion::from_tag("24.04.11.2.1"), Ok(StandardVersion { major: StandardVersionPart { value: 24, length: 0 }, minor: Some(StandardVersionPart { value: 4, length: 2 }), patch: Some(StandardVersionPart { value: 11, length: 0 }) , format_str: String::from("{}.2.1") }));
|
||||
assert_eq!(StandardVersion::from_tag("example15-test"), Ok(StandardVersion { major: StandardVersionPart { value: 15, length: 0 }, minor: None, patch: None , format_str: String::from("example{}-test") }));
|
||||
assert_eq!(StandardVersion::from_tag("watch-the-dot-5.3.2.careful"), Ok(StandardVersion { major: StandardVersionPart { value: 5, length: 0 }, minor: Some(StandardVersionPart { value: 3, length: 0 }), patch: Some(StandardVersionPart { value: 2, length: 0 }), format_str: String::from("watch-the-dot-{}.careful") }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_part() {
|
||||
assert_eq!(
|
||||
format!(
|
||||
"{:?}",
|
||||
StandardVersionPart {
|
||||
value: 21,
|
||||
length: 4
|
||||
}
|
||||
),
|
||||
String::from("0021")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ pub struct Update {
|
||||
pub result: UpdateResult,
|
||||
pub time: u32,
|
||||
pub server: Option<String>,
|
||||
pub used_by: Vec<String>,
|
||||
pub in_use: bool,
|
||||
#[serde(skip_serializing, skip_deserializing)]
|
||||
pub status: Status,
|
||||
}
|
||||
@@ -24,14 +24,14 @@ pub struct UpdateResult {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(PartialEq))]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[cfg_attr(test, derive(PartialEq, Default))]
|
||||
#[serde(untagged)]
|
||||
pub enum UpdateInfo {
|
||||
#[default]
|
||||
#[cfg_attr(test, default)]
|
||||
None,
|
||||
Version(VersionUpdateInfo),
|
||||
Digest(String), // Remote digest
|
||||
Digest(DigestUpdateInfo),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
@@ -104,4 +104,4 @@ impl Update {
|
||||
status => status.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,188 @@
|
||||
use crate::{config::TagType, structs::standard_version::StandardVersion};
|
||||
use std::{cmp::Ordering, fmt::Display};
|
||||
|
||||
#[derive(Clone, Default, PartialEq, Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum Version {
|
||||
#[default]
|
||||
Unknown,
|
||||
Semver(StandardVersion),
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
|
||||
use super::status::Status;
|
||||
|
||||
/// 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 {
|
||||
pub fn from_standard(tag: &str) -> Result<Self, ()> {
|
||||
match StandardVersion::from_tag(tag) {
|
||||
Ok(version) => Ok(Version::Semver(version)),
|
||||
Err(e) => Err(e),
|
||||
/// Tries to parse the tag into semver-like parts. Returns a Version object and a string usable in format! with {} in the positions matches were found
|
||||
pub fn from_tag(tag: &str) -> Option<(Self, String)> {
|
||||
/// 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.
|
||||
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(
|
||||
r"(?P<major>0|[1-9][0-9]*)(?:\.(?P<minor>0|[1-9][0-9]*))?(?:\.(?P<patch>0|[1-9][0-9]*))?",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let captures = VERSION_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 mut positions = Vec::new();
|
||||
let major: u32 = match c.name("major") {
|
||||
Some(major) => {
|
||||
positions.push((major.start(), major.end()));
|
||||
match major.as_str().parse() {
|
||||
Ok(m) => m,
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
let minor: Option<u32> = c.name("minor").map(|minor| {
|
||||
positions.push((minor.start(), minor.end()));
|
||||
minor
|
||||
.as_str()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("Minor version invalid in tag {}", tag))
|
||||
});
|
||||
let patch: Option<u32> = c.name("patch").map(|patch| {
|
||||
positions.push((patch.start(), patch.end()));
|
||||
patch
|
||||
.as_str()
|
||||
.parse()
|
||||
.unwrap_or_else(|_| panic!("Patch version invalid in tag {}", tag))
|
||||
});
|
||||
let mut format_str = tag.to_string();
|
||||
positions.reverse();
|
||||
positions.iter().for_each(|(start, end)| {
|
||||
format_str.replace_range(*start..*end, "{}");
|
||||
});
|
||||
Some((
|
||||
Version {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
},
|
||||
format_str,
|
||||
))
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_string(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Semver(v) => Some(v.format_str.clone()),
|
||||
Self::Unknown => None,
|
||||
pub fn to_status(&self, base: &Self) -> Status {
|
||||
match self.major.cmp(&base.major) {
|
||||
Ordering::Greater => Status::UpdateMajor,
|
||||
Ordering::Equal => match (self.minor, base.minor) {
|
||||
(Some(a_minor), Some(b_minor)) => match a_minor.cmp(&b_minor) {
|
||||
Ordering::Greater => Status::UpdateMinor,
|
||||
Ordering::Equal => match (self.patch, base.patch) {
|
||||
(Some(a_patch), Some(b_patch)) => match a_patch.cmp(&b_patch) {
|
||||
Ordering::Greater => Status::UpdatePatch,
|
||||
Ordering::Equal => Status::UpToDate,
|
||||
Ordering::Less => {
|
||||
Status::Unknown(format!("Tag {} does not exist", base))
|
||||
}
|
||||
},
|
||||
(None, None) => Status::UpToDate,
|
||||
_ => unreachable!(),
|
||||
},
|
||||
Ordering::Less => Status::Unknown(format!("Tag {} does not exist", base)),
|
||||
},
|
||||
(None, None) => Status::UpToDate,
|
||||
_ => unreachable!(
|
||||
"Version error: {} and {} should either both be Some or None",
|
||||
self, base
|
||||
),
|
||||
},
|
||||
Ordering::Less => Status::Unknown(format!("Tag {} does not exist", base)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from(tag: &str, tag_type: Option<&TagType>) -> Self {
|
||||
match tag_type {
|
||||
Some(t) => match t {
|
||||
TagType::Standard => Self::from_standard(tag).unwrap_or(Self::Unknown),
|
||||
TagType::Extended => unimplemented!(),
|
||||
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,
|
||||
},
|
||||
None => match Self::from_standard(tag) {
|
||||
Ok(v) => v,
|
||||
Err(_) => Self::Unknown, // match self.from_...
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn r#type(&self) -> Option<TagType> {
|
||||
match self {
|
||||
Self::Semver(_) => Some(TagType::Standard),
|
||||
Self::Unknown => None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_standard(&self) -> Option<&StandardVersion> {
|
||||
match self {
|
||||
Self::Semver(s) => Some(s),
|
||||
_ => None
|
||||
_ => major_ordering,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
match (self, other) {
|
||||
(Self::Unknown, Self::Unknown)
|
||||
| (Self::Unknown, Self::Semver(_))
|
||||
| (Self::Semver(_), Self::Unknown) => None, // Could also just implement the other arms first and leave this as _, but better be explicit rather than implicit
|
||||
(Self::Semver(a), Self::Semver(b)) => a.partial_cmp(b),
|
||||
}
|
||||
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) }, String::from("{}.{}.{}" ))));
|
||||
assert_eq!(Version::from_tag("14" ), Some((Version { major: 14, minor: None, patch: None }, String::from("{}" ))));
|
||||
assert_eq!(Version::from_tag("v0.107.53" ), Some((Version { major: 0, minor: Some(107), patch: Some(53) }, String::from("v{}.{}.{}" ))));
|
||||
assert_eq!(Version::from_tag("12-alpine" ), Some((Version { major: 12, minor: None, patch: None }, String::from("{}-alpine" ))));
|
||||
assert_eq!(Version::from_tag("0.9.5-nginx" ), Some((Version { major: 0, minor: Some(9), patch: Some(5) }, String::from("{}.{}.{}-nginx" ))));
|
||||
assert_eq!(Version::from_tag("v27.0" ), Some((Version { major: 27, minor: Some(0), patch: None }, String::from("v{}.{}" ))));
|
||||
assert_eq!(Version::from_tag("16.1" ), Some((Version { major: 16, minor: Some(1), patch: None }, String::from("{}.{}" ))));
|
||||
assert_eq!(Version::from_tag("version-1.5.6" ), Some((Version { major: 1, minor: Some(5), patch: Some(6) }, String::from("version-{}.{}.{}" ))));
|
||||
assert_eq!(Version::from_tag("15.4-alpine" ), Some((Version { major: 15, minor: Some(4), patch: None }, String::from("{}.{}-alpine" ))));
|
||||
assert_eq!(Version::from_tag("pg14-v0.2.0" ), Some((Version { major: 0, minor: Some(2), patch: Some(0) }, String::from("pg14-v{}.{}.{}" ))));
|
||||
assert_eq!(Version::from_tag("18-jammy-full.s6-v0.88.0"), Some((Version { major: 0, minor: Some(88), patch: Some(0) }, String::from("18-jammy-full.s6-v{}.{}.{}"))));
|
||||
assert_eq!(Version::from_tag("fpm-2.1.0-prod" ), Some((Version { major: 2, minor: Some(1), patch: Some(0) }, String::from("fpm-{}.{}.{}-prod" ))));
|
||||
assert_eq!(Version::from_tag("7.3.3.50" ), Some((Version { major: 7, minor: Some(3), patch: Some(3) }, String::from("{}.{}.{}.50" ))));
|
||||
assert_eq!(Version::from_tag("1.21.11-0" ), Some((Version { major: 1, minor: Some(21), patch: Some(11) }, String::from("{}.{}.{}-0" ))));
|
||||
assert_eq!(Version::from_tag("4.1.2.1-full" ), Some((Version { major: 4, minor: Some(1), patch: Some(2) }, String::from("{}.{}.{}.1-full" ))));
|
||||
assert_eq!(Version::from_tag("v4.0.3-ls215" ), Some((Version { major: 4, minor: Some(0), patch: Some(3) }, String::from("v{}.{}.{}-ls215" ))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Functions that return JSON data, used for generating output and API responses
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::structs::{status::Status, update::Update};
|
||||
|
||||
@@ -47,8 +47,27 @@ pub fn get_metrics(updates: &[Update]) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// Takes a slice of `Image` objects and returns a `Value` with update info. The output doesn't contain much detail
|
||||
pub fn to_simple_json(updates: &[Update]) -> Value {
|
||||
let mut update_map = Map::new();
|
||||
updates.iter().for_each(|update| {
|
||||
let _ = update_map.insert(
|
||||
update.reference.clone(),
|
||||
match update.result.has_update {
|
||||
Some(has_update) => Value::Bool(has_update),
|
||||
None => Value::Null,
|
||||
},
|
||||
);
|
||||
});
|
||||
let json_data: Value = json!({
|
||||
"metrics": get_metrics(updates),
|
||||
"images": updates,
|
||||
});
|
||||
json_data
|
||||
}
|
||||
|
||||
/// Takes a slice of `Image` objects and returns a `Value` with update info. All image data is included, useful for debugging.
|
||||
pub fn to_json(updates: &[Update]) -> Value {
|
||||
pub fn to_full_json(updates: &[Update]) -> Value {
|
||||
json!({
|
||||
"metrics": get_metrics(updates),
|
||||
"images": updates.iter().map(|update| serde_json::to_value(update).unwrap()).collect::<Vec<Value>>(),
|
||||
|
||||
@@ -123,7 +123,7 @@ function App() {
|
||||
<Server name={server} key={server}>
|
||||
{images
|
||||
.filter((image) =>
|
||||
filters.onlyInUse ? image.used_by.length > 0 : true,
|
||||
filters.onlyInUse ? !!image.in_use : true,
|
||||
)
|
||||
.filter((image) =>
|
||||
filters.registries.length == 0
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { theme } from "../theme";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export default function Badge({ children, className }: { children: ReactNode, className?: string }) {
|
||||
export default function Badge({ from, to }: { from: string; to: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(`inline-flex items-center rounded-full bg-${theme}-50 px-2 py-1 text-xs font-medium text-${theme}-700 ring-1 ring-inset ring-${theme}-700/10 dark:bg-${theme}-400/10 dark:text-${theme}-400 dark:ring-${theme}-400/30 break-keep`, className)}
|
||||
className={`hidden sm:block inline-flex items-center rounded-full bg-${theme}-50 px-2 py-1 text-xs font-medium text-${theme}-700 ring-1 ring-inset ring-${theme}-700/10 dark:bg-${theme}-400/10 dark:text-${theme}-400 dark:ring-${theme}-400/30 break-keep`}
|
||||
>
|
||||
{children}
|
||||
{from}
|
||||
<ArrowRight className="size-3" />
|
||||
{to}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,16 @@ import type { Image } from "../types";
|
||||
import { theme } from "../theme";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import {
|
||||
ArrowRight,
|
||||
Box,
|
||||
CircleArrowUp,
|
||||
CircleCheck,
|
||||
Container,
|
||||
HelpCircle,
|
||||
Timer,
|
||||
TriangleAlert,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Badge from "./Badge";
|
||||
import { getDescription, truncateArray } from "../utils";
|
||||
import { getDescription } from "../utils";
|
||||
|
||||
const clickable_registries = [
|
||||
"registry-1.docker.io",
|
||||
@@ -66,16 +64,11 @@ export default function Image({ data }: { data: Image }) {
|
||||
<span className="font-mono">{data.reference}</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{data.result.info?.type === "version" ? (
|
||||
<Badge className="hidden sm:inline-flex">
|
||||
{data.result.info.current_version}
|
||||
<ArrowRight className="size-3" />
|
||||
{data.result.info.new_version}
|
||||
</Badge>
|
||||
<Badge
|
||||
from={data.result.info.current_version}
|
||||
to={data.result.info.new_version}
|
||||
/>
|
||||
) : null}
|
||||
<Badge className="hidden md:inline-flex">
|
||||
<Container className="size-4 mr-1"/>
|
||||
{data.used_by.length}
|
||||
</Badge>
|
||||
<WithTooltip
|
||||
text={info.description}
|
||||
className={`size-6 shrink-0 ${info.color}`}
|
||||
@@ -147,21 +140,6 @@ export default function Image({ data }: { data: Image }) {
|
||||
Checked in <b>{data.time}</b> ms
|
||||
</span>
|
||||
</div>
|
||||
{data.used_by.length !== 0 && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Container className="size-6 shrink-0 text-gray-500" />
|
||||
<Disclosure as="div">
|
||||
<DisclosureButton className="inline-flex items-end group">Used by {truncateArray(data.used_by)}<ChevronDown className="shrink-0 size-5 group-data-[open]:rotate-180"/></DisclosureButton>
|
||||
<DisclosurePanel className="origin-top transition duration-200 ease-out data-[closed]:-translate-y-6 data-[closed]:opacity-0 mt-4 rounded-lg bg-black/50 px-3 py-2" transition>
|
||||
<table>
|
||||
<tbody className="divide-y divide-white/10">
|
||||
{data.used_by.map((container) => <tr className="divide divide-white/10 divide-x group"><td className="px-2 py-1 group-first:pt-2 group-last:pb-2"><pre>{container}</pre></td><td className="px-2 py-1 group-first:pt-2 group-last:pb-2"><button className="inline-flex gap-1 items-center">Update<CircleArrowUp className="size-5"/></button></td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
)}
|
||||
{data.result.error && (
|
||||
<div className="break-before mt-4 flex items-center gap-3 overflow-hidden rounded-md bg-yellow-400/10 px-3 py-2">
|
||||
<TriangleAlert className="size-6 shrink-0 text-yellow-500" />
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@headlessui/react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
import { theme } from "../../theme";
|
||||
import { cn, truncateArray } from "../../utils";
|
||||
import { cn } from "../../utils";
|
||||
import { Server } from "lucide-react";
|
||||
|
||||
export default function Select({
|
||||
@@ -27,7 +27,7 @@ export default function Select({
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
className={cn(
|
||||
`flex w-full gap-2 overflow-x-hidden rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 group relative items-center py-1.5 pl-3 pr-2 text-left transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500 sm:text-sm/6`,
|
||||
`flex overflow-x-hidden w-full gap-2 rounded-md bg-${theme}-100 dark:bg-${theme}-900 border border-${theme}-200 dark:border-${theme}-700 group relative items-center py-1.5 pl-3 pr-2 text-left transition-colors duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500 sm:text-sm/6`,
|
||||
selectedItems.length == 0
|
||||
? `text-${theme}-600 dark:text-${theme}-400 hover:text-black hover:dark:text-white`
|
||||
: "text-black dark:text-white",
|
||||
@@ -44,14 +44,15 @@ export default function Select({
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{selectedItems.length == 0
|
||||
? placeholder
|
||||
: truncateArray(selectedItems)}
|
||||
</span>
|
||||
{selectedItems.length == 0
|
||||
? placeholder
|
||||
: selectedItems.length == 1
|
||||
? selectedItems[0]
|
||||
: `${selectedItems[0]} +${(selectedItems.length - 1).toString()} more`}</span>
|
||||
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={`ml-auto size-5 shrink-0 self-center text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 group-hover:text-black sm:size-4 group-hover:dark:text-white`}
|
||||
className={`size-5 shrink-0 ml-auto self-center text-${theme}-600 dark:text-${theme}-400 transition-colors duration-200 group-hover:text-black sm:size-4 group-hover:dark:text-white`}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-px left-1/2 h-full w-0 -translate-x-1/2 rounded-md border-b-2 border-b-blue-600 transition-all duration-200 group-data-[open]:w-[calc(100%+2px)]"
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface Image {
|
||||
};
|
||||
time: number;
|
||||
server: string | null;
|
||||
used_by: string[];
|
||||
in_use: boolean | null;
|
||||
}
|
||||
|
||||
interface VersionInfo {
|
||||
|
||||
@@ -28,11 +28,3 @@ export function getDescription(image: Image) {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function truncateArray(arr: string[]) {
|
||||
if (arr.length > 1) {
|
||||
return `${arr[0]} +${(arr.length - 1).toString()} more`
|
||||
} else if (arr.length == 1) {
|
||||
return arr[0]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user