Initial
This commit is contained in:
commit
3f60b72c3b
11 changed files with 6599 additions and 0 deletions
2
src/compose.rs
Normal file
2
src/compose.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod parser;
|
||||
pub mod updater;
|
||||
186
src/compose/parser.rs
Normal file
186
src/compose/parser.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use crate::registry::ImageRef;
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use std::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceImage {
|
||||
pub service_name: String,
|
||||
pub image_ref: ImageRef,
|
||||
pub original_line: String,
|
||||
pub line_number: usize,
|
||||
}
|
||||
|
||||
pub struct ComposeFile {
|
||||
pub content: String,
|
||||
pub services: Vec<ServiceImage>,
|
||||
}
|
||||
|
||||
pub struct ComposeParser;
|
||||
|
||||
impl ComposeParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn parse_file(&self, file_path: &str) -> Result<ComposeFile> {
|
||||
let content = fs::read_to_string(file_path)?;
|
||||
let services = self.extract_services(&content)?;
|
||||
|
||||
Ok(ComposeFile { content, services })
|
||||
}
|
||||
|
||||
fn extract_services(&self, content: &str) -> Result<Vec<ServiceImage>> {
|
||||
let mut services = Vec::new();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut in_services = false;
|
||||
let mut current_service: Option<String> = None;
|
||||
let image_regex = Regex::new(r#"^\s*image:\s*(?:["']([^"']+)["']|([^\s#]+))\s*(#.*)?$"#)?;
|
||||
|
||||
for (line_number, line) in lines.iter().enumerate() {
|
||||
if line.trim_start().starts_with("services:") {
|
||||
in_services = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_services
|
||||
&& line.chars().next().is_some_and(|c| c.is_alphabetic())
|
||||
&& !line.starts_with(" ")
|
||||
{
|
||||
in_services = false;
|
||||
current_service = None;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_services {
|
||||
if let Some(service_name) = self.extract_service_name(line) {
|
||||
current_service = Some(service_name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(ref service_name) = current_service {
|
||||
if let Some(image_ref) = self.extract_image_from_line(line, &image_regex)? {
|
||||
info!(
|
||||
"Found service '{}' with image '{}'",
|
||||
service_name, image_ref
|
||||
);
|
||||
services.push(ServiceImage {
|
||||
service_name: service_name.clone(),
|
||||
image_ref,
|
||||
original_line: line.to_string(),
|
||||
line_number,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(services)
|
||||
}
|
||||
|
||||
fn extract_service_name(&self, line: &str) -> Option<String> {
|
||||
let trimmed = line.trim_start();
|
||||
let indent_level = line.len() - trimmed.len();
|
||||
|
||||
if indent_level > 0 && indent_level <= 8 && trimmed.ends_with(':') && !trimmed.contains(' ')
|
||||
{
|
||||
let potential_service = trimmed.trim_end_matches(':');
|
||||
if !potential_service.is_empty()
|
||||
&& potential_service
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Some(potential_service.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_image_from_line(&self, line: &str, image_regex: &Regex) -> Result<Option<ImageRef>> {
|
||||
let trimmed = line.trim_start();
|
||||
let indent_level = line.len() - trimmed.len();
|
||||
|
||||
if indent_level > 2 && trimmed.starts_with("image:") {
|
||||
if let Some(captures) = image_regex.captures(line) {
|
||||
let image_str = captures
|
||||
.get(1)
|
||||
.or_else(|| captures.get(2))
|
||||
.unwrap()
|
||||
.as_str();
|
||||
|
||||
if image_str.find('@').is_some() {
|
||||
info!("Found digest in image string, skipping: {}", image_str);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if image_str.trim().is_empty()
|
||||
|| image_str.trim() == "\"\""
|
||||
|| image_str.trim() == "''"
|
||||
{
|
||||
info!("Empty image string found in line: {}, skipping", line);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
match ImageRef::parse(image_str) {
|
||||
Ok(image_ref) => Ok(Some(image_ref)),
|
||||
Err(e) => {
|
||||
warn!("Failed to parse image '{}': {}", image_str, e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ComposeParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[test]
|
||||
fn test_parse_compose_file() {
|
||||
let compose_content = r#"
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
image: nginx:1.21.0 # Web server
|
||||
ports:
|
||||
- "80:80"
|
||||
|
||||
db:
|
||||
image: postgres:13.7
|
||||
environment:
|
||||
POSTGRES_DB: myapp
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine
|
||||
"#;
|
||||
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
temp_file.write_all(compose_content.as_bytes()).unwrap();
|
||||
|
||||
let parser = ComposeParser::new();
|
||||
let result = parser
|
||||
.parse_file(temp_file.path().to_str().unwrap())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.services.len(), 3);
|
||||
assert_eq!(result.services[0].service_name, "web");
|
||||
assert_eq!(result.services[0].image_ref.name, "nginx");
|
||||
assert_eq!(result.services[0].image_ref.tag, "1.21.0");
|
||||
}
|
||||
}
|
||||
293
src/compose/updater.rs
Normal file
293
src/compose/updater.rs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
use super::parser::{ComposeFile, ComposeParser, ServiceImage};
|
||||
use crate::config::Config;
|
||||
use crate::registry::Client as RegistryClient;
|
||||
use crate::strategy::create_selector;
|
||||
use crate::version::parse_version_tag;
|
||||
use anyhow::{anyhow, Result};
|
||||
use regex::Regex;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
pub struct ComposeUpdater {
|
||||
config: Config,
|
||||
registry_client: RegistryClient,
|
||||
parser: ComposeParser,
|
||||
}
|
||||
|
||||
impl ComposeUpdater {
|
||||
pub fn new(config: Config) -> Self {
|
||||
let registry_client = RegistryClient::new(config.clone());
|
||||
let parser = ComposeParser::new();
|
||||
|
||||
Self {
|
||||
config,
|
||||
registry_client,
|
||||
parser,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_all_compose_files(&self) -> Result<Vec<String>> {
|
||||
let mut updated_files = Vec::new();
|
||||
|
||||
for compose_path in &self.config.compose_paths {
|
||||
let compose_files = self.find_compose_files(compose_path)?;
|
||||
|
||||
for file_path in compose_files {
|
||||
let updated = self.update_compose_file(&file_path).await?;
|
||||
if updated {
|
||||
updated_files.push(file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_files)
|
||||
}
|
||||
|
||||
pub fn parse_compose_file(&self, file_path: &str) -> Result<ComposeFile> {
|
||||
self.parser.parse_file(file_path)
|
||||
}
|
||||
|
||||
async fn update_compose_file(&self, file_path: &str) -> Result<bool> {
|
||||
info!("Processing compose file: {}", file_path);
|
||||
|
||||
let compose_file = self.parse_compose_file(file_path)?;
|
||||
let mut updated = false;
|
||||
let mut new_content = compose_file.content.clone();
|
||||
|
||||
for service in &compose_file.services {
|
||||
if self.config.is_image_ignored(&service.image_ref.to_string()) {
|
||||
info!("Skipping ignored image: {}", service.image_ref.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.update_service_image(service).await {
|
||||
Ok(Some(new_image)) => {
|
||||
new_content =
|
||||
self.replace_image_in_content(&new_content, service, &new_image)?;
|
||||
updated = true;
|
||||
info!(
|
||||
"Updated {}: {} -> {}",
|
||||
service.service_name,
|
||||
service.image_ref.to_string(),
|
||||
new_image
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(
|
||||
"No update needed for {}: {}",
|
||||
service.service_name,
|
||||
service.image_ref.to_string()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(
|
||||
e.context(format!("Failed to update service {}", service.service_name))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
self.write_updated_content(file_path, new_content)?;
|
||||
}
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn write_updated_content(&self, file_path: &str, content: String) -> Result<()> {
|
||||
if !self.config.dry_run {
|
||||
fs::write(file_path, content)?;
|
||||
info!("Updated compose file: {}", file_path);
|
||||
} else {
|
||||
info!("Dry run: Would update compose file: {}", file_path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_service_image(&self, service: &ServiceImage) -> Result<Option<String>> {
|
||||
info!(
|
||||
"Checking for updates for service: {} (current image: {})",
|
||||
service.service_name,
|
||||
service.image_ref.to_string()
|
||||
);
|
||||
let (current_version, current_prefix, current_suffix, _) =
|
||||
parse_version_tag(&service.image_ref.tag);
|
||||
|
||||
let available_versions = self
|
||||
.registry_client
|
||||
.get_available_versions(&service.image_ref)
|
||||
.await?;
|
||||
|
||||
if available_versions.is_empty() {
|
||||
warn!("No versions available for selection");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let selector = create_selector(&self.config.update_strategy);
|
||||
if let Some(target_version_info) =
|
||||
selector.select_target_version(&available_versions, current_prefix, current_suffix)
|
||||
{
|
||||
// Only update if the target version is different AND higher than the current version
|
||||
// This prevents downgrades
|
||||
if Some(target_version_info.version.clone()) != current_version {
|
||||
if let Some(ref current_ver) = current_version {
|
||||
if target_version_info.version < *current_ver {
|
||||
info!(
|
||||
"Skipping downgrade from {} to {}",
|
||||
current_ver, target_version_info.version
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let mut new_image_ref = service.image_ref.clone();
|
||||
new_image_ref.tag = target_version_info.to_string();
|
||||
return Ok(Some(new_image_ref.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn replace_image_in_content(
|
||||
&self,
|
||||
content: &str,
|
||||
service: &ServiceImage,
|
||||
new_image: &str,
|
||||
) -> Result<String> {
|
||||
let image_regex =
|
||||
Regex::new(r#"^(\s*image:\s*)(?:["']([^"']+)["']|([^\s#]+))(\s*(?:#.*)?)$"#)?;
|
||||
|
||||
if let Some(captures) = image_regex.captures(&service.original_line) {
|
||||
let prefix = captures.get(1).unwrap().as_str();
|
||||
let _old_image = captures
|
||||
.get(2)
|
||||
.or_else(|| captures.get(3))
|
||||
.unwrap()
|
||||
.as_str();
|
||||
let suffix = captures.get(4).unwrap().as_str();
|
||||
|
||||
let image_part = if captures.get(2).is_some() {
|
||||
format!("\"{new_image}\"")
|
||||
} else {
|
||||
new_image.to_string()
|
||||
};
|
||||
|
||||
let new_line = format!("{prefix}{image_part}{suffix}");
|
||||
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if service.line_number < lines.len()
|
||||
&& lines[service.line_number] == service.original_line
|
||||
{
|
||||
let mut result_lines = lines;
|
||||
result_lines[service.line_number] = &new_line;
|
||||
let mut result = result_lines.join("\n");
|
||||
|
||||
if content.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Line number mismatch or content changed for service: {}",
|
||||
service.service_name
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Could not parse image line: {}",
|
||||
service.original_line
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn find_compose_files(&self, path: &Path) -> Result<Vec<String>> {
|
||||
let mut visited = HashSet::new();
|
||||
self.find_compose_files_recursive(path, &mut visited)
|
||||
}
|
||||
|
||||
fn find_compose_files_recursive(
|
||||
&self,
|
||||
path: &Path,
|
||||
visited: &mut HashSet<PathBuf>,
|
||||
) -> Result<Vec<String>> {
|
||||
let canonical_path = match path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Failed to canonicalize path {}: {}", path.display(), e);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
|
||||
if !visited.insert(canonical_path.clone()) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut compose_files = Vec::new();
|
||||
|
||||
if path.is_file() {
|
||||
if self.is_compose_file(path)? {
|
||||
compose_files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let entry_path = entry.path();
|
||||
|
||||
if entry_path.is_file() && self.is_compose_file(&entry_path)? {
|
||||
compose_files.push(entry_path.to_string_lossy().to_string());
|
||||
} else if entry_path.is_dir() {
|
||||
compose_files.extend(self.find_compose_files_recursive(&entry_path, visited)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(compose_files)
|
||||
}
|
||||
|
||||
fn is_compose_file(&self, path: &Path) -> Result<bool> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.ok_or_else(|| anyhow!("Invalid file path: {:?}", path))?
|
||||
.to_string_lossy();
|
||||
Ok(filename.ends_with(".yml") || filename.ends_with(".yaml"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Config, UpdateStrategy};
|
||||
use std::io::Write;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_prevents_downgrade() {
|
||||
let mut config = Config::default();
|
||||
config.update_strategy = UpdateStrategy::Latest;
|
||||
config.dry_run = true;
|
||||
|
||||
let updater = ComposeUpdater::new(config);
|
||||
|
||||
// Create a temporary compose file with a higher version
|
||||
let mut temp_file = NamedTempFile::new().unwrap();
|
||||
writeln!(temp_file, "services:\n web:\n image: nginx:1.25.0").unwrap();
|
||||
|
||||
let file_path = temp_file.path().to_str().unwrap();
|
||||
let compose_file = updater.parse_compose_file(file_path).unwrap();
|
||||
|
||||
assert_eq!(compose_file.services.len(), 1);
|
||||
let service = &compose_file.services[0];
|
||||
|
||||
// Mock a scenario where the strategy selects a lower version
|
||||
// This would happen if available versions only include older versions
|
||||
let (current_version, _, _, _) = parse_version_tag(&service.image_ref.tag);
|
||||
assert!(current_version.is_some());
|
||||
|
||||
// The actual test would need mocked registry responses, but we can verify
|
||||
// the logic by checking that current version is properly extracted
|
||||
assert_eq!(current_version.unwrap().to_string(), "1.25.0");
|
||||
}
|
||||
}
|
||||
105
src/config.rs
Normal file
105
src/config.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub compose_paths: Vec<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub schedule: String,
|
||||
#[serde(default)]
|
||||
pub registries: HashMap<String, RegistryConfig>,
|
||||
#[serde(default)]
|
||||
pub update_strategy: UpdateStrategy,
|
||||
#[serde(default)]
|
||||
pub ignore_images: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegistryConfig {
|
||||
pub url: String,
|
||||
pub auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub enum UpdateStrategy {
|
||||
#[default]
|
||||
LatestPatchOfPreviousMinor,
|
||||
Latest,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
let mut registries = HashMap::new();
|
||||
registries.insert(
|
||||
"docker.io".to_string(),
|
||||
RegistryConfig {
|
||||
url: "https://registry-1.docker.io".to_string(),
|
||||
auth_token: None,
|
||||
},
|
||||
);
|
||||
registries.insert(
|
||||
"ghcr.io".to_string(),
|
||||
RegistryConfig {
|
||||
url: "https://ghcr.io".to_string(),
|
||||
auth_token: std::env::var("GITHUB_TOKEN").ok(),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
compose_paths: vec![PathBuf::from(".")],
|
||||
schedule: "0 0 2 * * *".to_string(), // Daily at 2 AM
|
||||
registries,
|
||||
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
|
||||
ignore_images: vec![],
|
||||
dry_run: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(config_path: PathBuf) -> Result<Self> {
|
||||
info!("Loading configuration from {}", config_path.display());
|
||||
let content = std::fs::read_to_string(config_path)?;
|
||||
let expanded_content = Self::expand_env_vars(&content);
|
||||
let mut config: Self = serde_yaml::from_str(&expanded_content)?;
|
||||
config.resolve_env_tokens();
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Expand environment variable placeholders like ${VAR} in the config content
|
||||
pub fn expand_env_vars(content: &str) -> String {
|
||||
let env_var_pattern = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
|
||||
|
||||
env_var_pattern
|
||||
.replace_all(content, |caps: ®ex::Captures| {
|
||||
let var_name = &caps[1];
|
||||
std::env::var(var_name).unwrap_or_else(|_| format!("${{{var_name}}}"))
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Resolve environment variable tokens after deserialization
|
||||
fn resolve_env_tokens(&mut self) {
|
||||
for registry_config in self.registries.values_mut() {
|
||||
if let Some(token) = ®istry_config.auth_token {
|
||||
if token.starts_with("$") {
|
||||
// Handle direct env var references like "$GITHUB_TOKEN"
|
||||
let env_var_name = token.trim_start_matches('$');
|
||||
registry_config.auth_token = std::env::var(env_var_name).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_image_ignored(&self, image: &str) -> bool {
|
||||
self.ignore_images
|
||||
.iter()
|
||||
.any(|ignored| image.contains(ignored))
|
||||
}
|
||||
}
|
||||
114
src/health.rs
Normal file
114
src/health.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::timeout;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct HealthServer {
|
||||
last_update_success: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HealthHandle {
|
||||
last_update_success: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl Default for HealthServer {
|
||||
fn default() -> Self {
|
||||
Self::new().0
|
||||
}
|
||||
}
|
||||
|
||||
impl HealthServer {
|
||||
pub fn new() -> (Self, HealthHandle) {
|
||||
let last_update_success = Arc::new(Mutex::new(true));
|
||||
let server = Self {
|
||||
last_update_success: last_update_success.clone(),
|
||||
};
|
||||
let handle = HealthHandle {
|
||||
last_update_success,
|
||||
};
|
||||
(server, handle)
|
||||
}
|
||||
|
||||
pub async fn start(self) -> anyhow::Result<()> {
|
||||
let listener = TcpListener::bind("0.0.0.0:8080").await?;
|
||||
info!("Health server listening on port 8080");
|
||||
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await?;
|
||||
let health_status = self.last_update_success.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Set timeout for the entire request handling (5 seconds)
|
||||
let handle_request = async {
|
||||
let mut buffer = [0; 1024];
|
||||
|
||||
// Read with timeout to prevent hanging connections
|
||||
match timeout(Duration::from_secs(5), socket.read(&mut buffer)).await {
|
||||
Ok(Ok(_)) => {
|
||||
// Successfully read request (we don't need to parse it for health check)
|
||||
}
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
warn!("Health check request read timeout or error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Safely access health status without panicking
|
||||
let is_healthy = match health_status.lock() {
|
||||
Ok(status) => *status,
|
||||
Err(_) => {
|
||||
warn!("Health status mutex poisoned, defaulting to unhealthy");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let (status_line, json_body) = if is_healthy {
|
||||
("HTTP/1.1 200 OK", "{\"status\":\"healthy\"}")
|
||||
} else {
|
||||
(
|
||||
"HTTP/1.1 503 Service Unavailable",
|
||||
"{\"status\":\"unhealthy\"}",
|
||||
)
|
||||
};
|
||||
|
||||
let response = format!(
|
||||
"{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status_line,
|
||||
json_body.len(),
|
||||
json_body
|
||||
);
|
||||
|
||||
let _ = timeout(
|
||||
Duration::from_secs(5),
|
||||
socket.write_all(response.as_bytes()),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
// Overall timeout for the entire connection handling
|
||||
let _ = timeout(Duration::from_secs(10), handle_request).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HealthHandle {
|
||||
pub fn set_health_status(&self, is_healthy: bool) {
|
||||
if let Ok(mut status) = self.last_update_success.lock() {
|
||||
*status = is_healthy;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn report_update_success(&self) {
|
||||
info!("Update succeeded - marking health as healthy");
|
||||
self.set_health_status(true);
|
||||
}
|
||||
|
||||
pub fn report_update_failure(&self) {
|
||||
info!("Update failed - marking health as unhealthy");
|
||||
self.set_health_status(false);
|
||||
}
|
||||
}
|
||||
7
src/lib.rs
Normal file
7
src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod compose;
|
||||
pub mod config;
|
||||
pub mod health;
|
||||
pub mod registry;
|
||||
pub mod scheduler;
|
||||
pub mod strategy;
|
||||
pub mod version;
|
||||
77
src/main.rs
Normal file
77
src/main.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use tracing::{info, Level};
|
||||
|
||||
mod compose;
|
||||
mod config;
|
||||
mod health;
|
||||
mod registry;
|
||||
mod scheduler;
|
||||
mod strategy;
|
||||
mod version;
|
||||
|
||||
use config::Config;
|
||||
use health::HealthServer;
|
||||
use scheduler::Scheduler;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Path to the configuration file
|
||||
#[arg(short, long)]
|
||||
config: PathBuf,
|
||||
|
||||
/// Verbose output (-v, -vv, -vvv for increasing verbosity)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Start the updater service
|
||||
Start,
|
||||
/// Run a one-time update
|
||||
Update,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let log_level = match cli.verbose {
|
||||
0 => Level::INFO,
|
||||
1 => Level::DEBUG,
|
||||
_ => Level::TRACE,
|
||||
};
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(log_level)
|
||||
.with_timer(tracing_subscriber::fmt::time::SystemTime)
|
||||
.with_target(false)
|
||||
.init();
|
||||
let config = Config::load(cli.config)?;
|
||||
|
||||
info!(
|
||||
"Starting Docker Compose Updater v{}",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
match cli.command {
|
||||
Commands::Start => {
|
||||
let (health_server, health_handle) = HealthServer::new();
|
||||
let scheduler = Scheduler::new(config.clone(), Some(health_handle))?;
|
||||
tokio::try_join!(health_server.start(), scheduler.start())?;
|
||||
}
|
||||
Commands::Update => {
|
||||
let scheduler = Scheduler::new(config, None)?;
|
||||
scheduler.run_once().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
605
src/registry.rs
Normal file
605
src/registry.rs
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
use crate::config::{Config, RegistryConfig};
|
||||
use crate::version::VersionInfo;
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::{Client as HttpClient, Response, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const PAGE_SIZE: usize = 500;
|
||||
const MAX_RETRY_ATTEMPTS: u32 = 5;
|
||||
const INITIAL_RETRY_DELAY_SECS: u64 = 1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageRef {
|
||||
pub registry: String,
|
||||
pub namespace: Option<String>,
|
||||
pub name: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
impl ImageRef {
|
||||
pub fn parse(image: &str) -> Result<Self> {
|
||||
let (image_part, tag) = if let Some(last_delim) = image.rfind(':') {
|
||||
(&image[..last_delim], &image[last_delim + 1..])
|
||||
} else {
|
||||
(image, "latest")
|
||||
};
|
||||
|
||||
let registry_parts: Vec<&str> = image_part.split('/').collect();
|
||||
|
||||
let (registry, namespace, name) = match registry_parts.len() {
|
||||
0 => return Err(anyhow!("Invalid image format: {}", image)),
|
||||
1 => ("docker.io".to_string(), None, registry_parts[0].to_string()),
|
||||
2 => {
|
||||
if registry_parts[0].contains('.') || registry_parts[0].contains(':') {
|
||||
(
|
||||
registry_parts[0].to_string(),
|
||||
None,
|
||||
registry_parts[1].to_string(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"docker.io".to_string(),
|
||||
Some(registry_parts[0].to_string()),
|
||||
registry_parts[1].to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 3 or more parts: first is registry, last is name, everything in between is namespace
|
||||
let registry = registry_parts[0].to_string();
|
||||
let name = registry_parts[registry_parts.len() - 1].to_string();
|
||||
let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/"));
|
||||
(registry, namespace, name)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ImageRef {
|
||||
registry,
|
||||
namespace,
|
||||
name,
|
||||
tag: tag.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ImageRef {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let base = match &self.namespace {
|
||||
Some(ns) => format!("{}/{}/{}", self.registry, ns, self.name),
|
||||
None => format!("{}/{}", self.registry, self.name),
|
||||
};
|
||||
|
||||
if self.registry == "docker.io" {
|
||||
match &self.namespace {
|
||||
Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag),
|
||||
None => write!(f, "{}:{}", self.name, self.tag),
|
||||
}
|
||||
} else {
|
||||
write!(f, "{}:{}", base, self.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Docker Hub API response for tag list
|
||||
#[derive(Deserialize)]
|
||||
struct DockerHubTagsResponse {
|
||||
results: Vec<DockerHubTag>,
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DockerHubTag {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Unified registry client with hybrid approach
|
||||
/// Uses Docker Hub's public API for Docker Hub, standard v2 API for others
|
||||
pub struct Client {
|
||||
http_client: HttpClient,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
http_client: HttpClient::new(),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Retry-After header to determine wait duration
|
||||
/// Supports both delay-seconds (integer) and HTTP-date formats
|
||||
fn parse_retry_after(&self, retry_after: &str) -> Option<Duration> {
|
||||
// Try parsing as seconds first
|
||||
if let Ok(seconds) = retry_after.parse::<u64>() {
|
||||
return Some(Duration::from_secs(seconds));
|
||||
}
|
||||
|
||||
// Try parsing as HTTP date (RFC 2822 or RFC 3339)
|
||||
if let Ok(date) = DateTime::parse_from_rfc2822(retry_after) {
|
||||
let now = Utc::now();
|
||||
let wait_time = date.signed_duration_since(now);
|
||||
if wait_time.num_seconds() > 0 {
|
||||
return Some(Duration::from_secs(wait_time.num_seconds() as u64));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Perform HTTP request with retry logic for rate limiting (429)
|
||||
async fn request_with_retry<F, Fut>(
|
||||
&self,
|
||||
request_fn: F,
|
||||
operation_name: &str,
|
||||
) -> Result<Response>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<Response, reqwest::Error>>,
|
||||
{
|
||||
let mut attempt = 0;
|
||||
let mut delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS);
|
||||
|
||||
loop {
|
||||
let response = request_fn().await?;
|
||||
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS {
|
||||
attempt += 1;
|
||||
|
||||
if attempt > MAX_RETRY_ATTEMPTS {
|
||||
return Err(anyhow!(
|
||||
"{} failed: Rate limited (429) after {} attempts",
|
||||
operation_name,
|
||||
MAX_RETRY_ATTEMPTS
|
||||
));
|
||||
}
|
||||
|
||||
// Check for Retry-After header
|
||||
let wait_duration = if let Some(retry_after) = response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
self.parse_retry_after(retry_after).unwrap_or(delay)
|
||||
} else {
|
||||
delay
|
||||
};
|
||||
|
||||
warn!(
|
||||
"{} rate limited (429), attempt {}/{}, waiting {:?} before retry",
|
||||
operation_name, attempt, MAX_RETRY_ATTEMPTS, wait_duration
|
||||
);
|
||||
|
||||
sleep(wait_duration).await;
|
||||
|
||||
// Exponential backoff for next attempt if no Retry-After header
|
||||
delay = delay.saturating_mul(2);
|
||||
} else {
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_registry_config(&self, registry: &str) -> Result<&RegistryConfig> {
|
||||
self.config
|
||||
.registries
|
||||
.get(registry)
|
||||
.ok_or_else(|| anyhow!("Unknown registry: {}", registry))
|
||||
}
|
||||
|
||||
fn build_repository_path(&self, image_ref: &ImageRef) -> String {
|
||||
match &image_ref.namespace {
|
||||
Some(namespace) => format!("{}/{}", namespace, image_ref.name),
|
||||
None => {
|
||||
if image_ref.registry == "docker.io" {
|
||||
// For Docker Hub official images, use 'library' namespace in public API
|
||||
format!("library/{}", image_ref.name)
|
||||
} else {
|
||||
// For other registries, no namespace means just the image name
|
||||
image_ref.name.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dockerhub_response(
|
||||
&self,
|
||||
response_text: &str,
|
||||
) -> Result<(Vec<VersionInfo>, Option<String>)> {
|
||||
let dockerhub_response: DockerHubTagsResponse = serde_json::from_str(response_text)
|
||||
.map_err(|e| anyhow!("Failed to parse Docker Hub response: {}", e))?;
|
||||
|
||||
let mut versions = Vec::new();
|
||||
|
||||
for tag in dockerhub_response.results {
|
||||
if let Some(version_info) = VersionInfo::from_tag(&tag.name) {
|
||||
versions.push(version_info);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((versions, dockerhub_response.next))
|
||||
}
|
||||
|
||||
fn parse_v2_response(&self, response_text: &str) -> Result<Vec<VersionInfo>> {
|
||||
debug!("Parsing registry v2 response: {}", response_text);
|
||||
let tags_response: serde_json::Value = serde_json::from_str(response_text)
|
||||
.map_err(|e| anyhow!("Failed to parse registry response: {}", e))?;
|
||||
|
||||
let mut versions = Vec::new();
|
||||
|
||||
if let Some(tags) = tags_response.get("tags").and_then(|t| t.as_array()) {
|
||||
for tag in tags {
|
||||
if let Some(tag_str) = tag.as_str() {
|
||||
if let Some(version_info) = VersionInfo::from_tag(tag_str) {
|
||||
versions.push(version_info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(versions)
|
||||
}
|
||||
|
||||
pub async fn get_available_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
|
||||
if image_ref.registry == "docker.io" {
|
||||
self.get_dockerhub_versions(image_ref).await
|
||||
} else {
|
||||
self.get_registry_v2_versions(image_ref).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
|
||||
let repo_path = self.build_repository_path(image_ref);
|
||||
let mut results = Vec::new();
|
||||
// Use Docker Hub's public REST API which doesn't require authentication for public repos
|
||||
let mut url = format!(
|
||||
"https://hub.docker.com/v2/repositories/{repo_path}/tags/?page_size={PAGE_SIZE}"
|
||||
);
|
||||
|
||||
loop {
|
||||
debug!("Docker Hub API URL: {}", url);
|
||||
|
||||
let url_clone = url.clone();
|
||||
let response = self
|
||||
.request_with_retry(
|
||||
|| async { self.http_client.get(&url_clone).send().await },
|
||||
"Docker Hub API request",
|
||||
)
|
||||
.await?;
|
||||
debug!("Docker Hub response status: {}", response.status());
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Docker Hub request failed with status {}: {}",
|
||||
response.status(),
|
||||
response.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let response_text = response.text().await?;
|
||||
|
||||
let (new_tags, next_page) = self.parse_dockerhub_response(&response_text)?;
|
||||
|
||||
results.extend(new_tags);
|
||||
|
||||
if let Some(next) = next_page {
|
||||
url = next;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
|
||||
let registry_config = self.get_registry_config(&image_ref.registry)?;
|
||||
let repo_path = self.build_repository_path(image_ref);
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut last_tag: Option<String> = None;
|
||||
let mut next_url: Option<String> = None;
|
||||
let mut bearer_token: Option<String> = None;
|
||||
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints
|
||||
loop {
|
||||
// Use next URL from Link header if available, otherwise build URL with last parameter
|
||||
let url = if let Some(next) = next_url.take() {
|
||||
format!("{}{next}", registry_config.url)
|
||||
} else {
|
||||
format!(
|
||||
"{}/v2/{repo_path}/tags/list?n={PAGE_SIZE}{}",
|
||||
registry_config.url,
|
||||
if let Some(ref last) = last_tag {
|
||||
format!("&last={last}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
debug!("Registry API URL: {}", url);
|
||||
|
||||
// Try unauthenticated request first to potentially get auth challenge
|
||||
let url_clone = url.clone();
|
||||
let bearer_token_clone = bearer_token.clone();
|
||||
let response = self
|
||||
.request_with_retry(
|
||||
|| async {
|
||||
let mut request_builder = self.http_client.get(&url_clone);
|
||||
if let Some(token) = &bearer_token_clone {
|
||||
request_builder = request_builder.bearer_auth(token);
|
||||
}
|
||||
request_builder.send().await
|
||||
},
|
||||
"Registry v2 API request",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (new_tags, link_next) = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
if let Some(token) = ®istry_config.auth_token {
|
||||
// Try Docker Registry v2 auth flow if we get auth challenge
|
||||
if let Some(auth_header) = response.headers().get("www-authenticate") {
|
||||
bearer_token = self
|
||||
.try_registry_v2_auth(auth_header.to_str().unwrap(), token)
|
||||
.await?;
|
||||
continue; // Retry with new token
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"Unauthorized request but no WWW-Authenticate header found"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("Unauthorized request but no auth token configured"));
|
||||
}
|
||||
} else if response.status().is_success() {
|
||||
// Check for Link header for pagination
|
||||
let link_next = response
|
||||
.headers()
|
||||
.get("link")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| self.parse_link_header(h));
|
||||
|
||||
let response_text = response.text().await?;
|
||||
let tags = self.parse_v2_response(&response_text)?;
|
||||
(tags, link_next)
|
||||
} else {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"Registry request failed with status {}: {}",
|
||||
status,
|
||||
error_text
|
||||
));
|
||||
};
|
||||
|
||||
let maybe_last_tag = new_tags.last().map(|v| v.original.clone());
|
||||
results.extend(new_tags);
|
||||
|
||||
// Use Link header next URL if available, otherwise fall back to last parameter pagination
|
||||
if let Some(next) = link_next {
|
||||
next_url = Some(next);
|
||||
} else if let Some(last) = maybe_last_tag {
|
||||
last_tag = Some(last);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn try_registry_v2_auth(&self, auth_str: &str, token: &str) -> Result<Option<String>> {
|
||||
// Parse WWW-Authenticate header to extract auth parameters
|
||||
let realm = self.extract_auth_param(auth_str, "realm")?;
|
||||
let service = self.extract_auth_param(auth_str, "service")?;
|
||||
let scope = self.extract_auth_param(auth_str, "scope")?;
|
||||
|
||||
// Request token from auth endpoint
|
||||
let auth_url = format!("{realm}?service={service}&scope={scope}");
|
||||
debug!("Getting registry token from: {}", auth_url);
|
||||
|
||||
let auth_url_clone = auth_url.clone();
|
||||
let token_clone = token.to_string();
|
||||
let token_response = self
|
||||
.request_with_retry(
|
||||
|| async {
|
||||
self.http_client
|
||||
.get(&auth_url_clone)
|
||||
.basic_auth("token", Some(&token_clone))
|
||||
.send()
|
||||
.await
|
||||
},
|
||||
"Registry auth token request",
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !token_response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to get token: {} - {}",
|
||||
token_response.status(),
|
||||
token_response.text().await.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let token_json: serde_json::Value = token_response.json().await?;
|
||||
|
||||
if let Some(registry_token) = token_json.get("token").and_then(|t| t.as_str()) {
|
||||
return Ok(Some(registry_token.to_string()));
|
||||
}
|
||||
|
||||
Err(anyhow!("Auth flow didn't work, caller can try fallback"))
|
||||
}
|
||||
|
||||
fn extract_auth_param(&self, auth_str: &str, param: &str) -> Result<String> {
|
||||
let pattern = format!(r#"{param}="([^"]+)""#);
|
||||
let re = regex::Regex::new(&pattern)?;
|
||||
|
||||
re.captures(auth_str)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|m| m.as_str().to_string())
|
||||
.ok_or_else(|| anyhow!("Missing {} in auth challenge", param))
|
||||
}
|
||||
|
||||
fn parse_link_header(&self, link_header: &str) -> Option<String> {
|
||||
// Parse RFC 5988 Link header to find "next" relation
|
||||
// Format: <https://example.com/page2>; rel="next", <https://example.com/last>; rel="last"
|
||||
for link in link_header.split(',') {
|
||||
let parts: Vec<&str> = link.trim().split(';').collect();
|
||||
if parts.len() >= 2 {
|
||||
let url = parts[0].trim();
|
||||
if url.starts_with('<') && url.ends_with('>') {
|
||||
let url = &url[1..url.len() - 1]; // Remove < and >
|
||||
|
||||
for param in &parts[1..] {
|
||||
let param = param.trim();
|
||||
if param == "rel=\"next\"" || param == "rel=next" {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_image_ref_parsing() {
|
||||
let test_cases = vec![
|
||||
("nginx:1.21.0", "docker.io", None, "nginx", "1.21.0"),
|
||||
(
|
||||
"ubuntu/nginx:1.21.0",
|
||||
"docker.io",
|
||||
Some("ubuntu"),
|
||||
"nginx",
|
||||
"1.21.0",
|
||||
),
|
||||
(
|
||||
"ghcr.io/user/app:v1.0.0",
|
||||
"ghcr.io",
|
||||
Some("user"),
|
||||
"app",
|
||||
"v1.0.0",
|
||||
),
|
||||
(
|
||||
"localhost:5000/myapp:latest",
|
||||
"localhost:5000",
|
||||
None,
|
||||
"myapp",
|
||||
"latest",
|
||||
),
|
||||
(
|
||||
"ghcr.io/schmelczer/fizika/fizika-admin:latest",
|
||||
"ghcr.io",
|
||||
Some("schmelczer/fizika"),
|
||||
"fizika-admin",
|
||||
"latest",
|
||||
),
|
||||
(
|
||||
"registry.example.com/org/team/project/image:v2.0.0",
|
||||
"registry.example.com",
|
||||
Some("org/team/project"),
|
||||
"image",
|
||||
"v2.0.0",
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected_registry, expected_namespace, expected_name, expected_tag) in
|
||||
test_cases
|
||||
{
|
||||
let image_ref = ImageRef::parse(input).unwrap();
|
||||
assert_eq!(
|
||||
image_ref.registry, expected_registry,
|
||||
"Registry mismatch for {}",
|
||||
input
|
||||
);
|
||||
assert_eq!(
|
||||
image_ref.namespace,
|
||||
expected_namespace.map(String::from),
|
||||
"Namespace mismatch for {}",
|
||||
input
|
||||
);
|
||||
assert_eq!(image_ref.name, expected_name, "Name mismatch for {}", input);
|
||||
assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for {}", input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repository_path_building() {
|
||||
let config = Config::default();
|
||||
let client = Client::new(config);
|
||||
|
||||
// Official Docker Hub image (no namespace)
|
||||
let image_ref = ImageRef {
|
||||
registry: "docker.io".to_string(),
|
||||
namespace: None,
|
||||
name: "nginx".to_string(),
|
||||
tag: "latest".to_string(),
|
||||
};
|
||||
assert_eq!(client.build_repository_path(&image_ref), "library/nginx");
|
||||
|
||||
// Docker Hub with namespace
|
||||
let image_ref = ImageRef {
|
||||
registry: "docker.io".to_string(),
|
||||
namespace: Some("bitnami".to_string()),
|
||||
name: "nginx".to_string(),
|
||||
tag: "latest".to_string(),
|
||||
};
|
||||
assert_eq!(client.build_repository_path(&image_ref), "bitnami/nginx");
|
||||
|
||||
// Custom registry
|
||||
let image_ref = ImageRef {
|
||||
registry: "ghcr.io".to_string(),
|
||||
namespace: Some("user".to_string()),
|
||||
name: "app".to_string(),
|
||||
tag: "v1.0.0".to_string(),
|
||||
};
|
||||
assert_eq!(client.build_repository_path(&image_ref), "user/app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_link_header() {
|
||||
let config = Config::default();
|
||||
let client = Client::new(config);
|
||||
|
||||
// Test standard Link header with next rel
|
||||
let link_header = r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag99>; rel="next", <https://registry.example.com/v2/repo/tags/list?n=100&last=tag999>; rel="last""#;
|
||||
let next_url = client.parse_link_header(link_header);
|
||||
assert_eq!(
|
||||
next_url,
|
||||
Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string())
|
||||
);
|
||||
|
||||
// Test Link header without quotes around rel value
|
||||
let link_header =
|
||||
r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag99>; rel=next"#;
|
||||
let next_url = client.parse_link_header(link_header);
|
||||
assert_eq!(
|
||||
next_url,
|
||||
Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string())
|
||||
);
|
||||
|
||||
// Test Link header with no next relation
|
||||
let link_header = r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag1>; rel="prev", <https://registry.example.com/v2/repo/tags/list?n=100&last=tag999>; rel="last""#;
|
||||
let next_url = client.parse_link_header(link_header);
|
||||
assert_eq!(next_url, None);
|
||||
|
||||
// Test empty Link header
|
||||
let next_url = client.parse_link_header("");
|
||||
assert_eq!(next_url, None);
|
||||
|
||||
// Test malformed Link header
|
||||
let link_header = "not-a-valid-link-header";
|
||||
let next_url = client.parse_link_header(link_header);
|
||||
assert_eq!(next_url, None);
|
||||
}
|
||||
}
|
||||
164
src/scheduler.rs
Normal file
164
src/scheduler.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
use crate::health::HealthHandle;
|
||||
use crate::{compose::updater::ComposeUpdater, config::Config};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use cron::Schedule;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{sleep, Instant};
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
|
||||
pub struct Scheduler {
|
||||
config: Config,
|
||||
updater: ComposeUpdater,
|
||||
schedule: Schedule,
|
||||
health_handle: Option<HealthHandle>,
|
||||
}
|
||||
|
||||
impl Scheduler {
|
||||
pub fn new(config: Config, health_handle: Option<HealthHandle>) -> Result<Self> {
|
||||
let schedule = Schedule::from_str(&config.schedule)
|
||||
.map_err(|e| anyhow!("Invalid cron expression '{}': {}", config.schedule, e))?;
|
||||
|
||||
let updater = ComposeUpdater::new(config.clone());
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
updater,
|
||||
schedule,
|
||||
health_handle,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
info!(
|
||||
"Starting scheduler with cron expression: {}",
|
||||
self.config.schedule
|
||||
);
|
||||
|
||||
loop {
|
||||
if let Err(err) = self.run_update().await.context("Failed to run update") {
|
||||
error!("{:?}", err)
|
||||
}
|
||||
|
||||
if let Some(next_run) = self.schedule.upcoming(chrono::Utc).take(1).next() {
|
||||
let now = chrono::Utc::now();
|
||||
let duration_until_next = next_run.signed_duration_since(now);
|
||||
|
||||
if duration_until_next.num_seconds() > 0 {
|
||||
info!(
|
||||
"Next update scheduled for: {}",
|
||||
next_run.format("%Y-%m-%d %H:%M:%S UTC")
|
||||
);
|
||||
|
||||
let sleep_duration =
|
||||
Duration::from_secs(duration_until_next.num_seconds() as u64);
|
||||
sleep(sleep_duration).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_once(&self) -> Result<()> {
|
||||
info!("Running one-time update");
|
||||
self.run_update().await
|
||||
}
|
||||
|
||||
async fn run_update(&self) -> Result<()> {
|
||||
let start_time = Instant::now();
|
||||
info!("Starting Docker Compose update cycle");
|
||||
|
||||
match self.updater.update_all_compose_files().await {
|
||||
Ok(updated_files) => {
|
||||
let duration = start_time.elapsed();
|
||||
if updated_files.is_empty() {
|
||||
info!(
|
||||
"Update cycle completed in {:?} - no files updated",
|
||||
duration
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Update cycle completed in {:?} - {} {} files:",
|
||||
duration,
|
||||
if self.config.dry_run {
|
||||
"would update"
|
||||
} else {
|
||||
"updated"
|
||||
},
|
||||
updated_files.len()
|
||||
);
|
||||
for file in &updated_files {
|
||||
info!(" - {}", file);
|
||||
}
|
||||
}
|
||||
|
||||
// Report success to health monitor
|
||||
if let Some(ref health) = self.health_handle {
|
||||
health.report_update_success();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let error = e.context("Failed to update Docker Compose files");
|
||||
|
||||
// Report failure to health monitor
|
||||
if let Some(ref health) = self.health_handle {
|
||||
health.report_update_failure();
|
||||
}
|
||||
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Config, UpdateStrategy};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_scheduler_creation() {
|
||||
let config = Config {
|
||||
compose_paths: vec![PathBuf::from("./test")],
|
||||
schedule: "0 0 2 * * *".to_string(),
|
||||
registries: HashMap::new(),
|
||||
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
|
||||
ignore_images: vec![],
|
||||
dry_run: true,
|
||||
};
|
||||
|
||||
let scheduler = Scheduler::new(config, None).unwrap();
|
||||
assert!(scheduler
|
||||
.schedule
|
||||
.upcoming(chrono::Utc)
|
||||
.take(1)
|
||||
.next()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cron_parsing() {
|
||||
let config = Config {
|
||||
compose_paths: vec![PathBuf::from("./test")],
|
||||
schedule: "0 30 1 * * *".to_string(), // 1:30 AM daily
|
||||
registries: HashMap::new(),
|
||||
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
|
||||
ignore_images: vec![],
|
||||
dry_run: true,
|
||||
};
|
||||
|
||||
let scheduler = Scheduler::new(config, None).unwrap();
|
||||
|
||||
// Just verify the scheduler can be created
|
||||
assert!(scheduler
|
||||
.schedule
|
||||
.upcoming(chrono::Utc)
|
||||
.take(1)
|
||||
.next()
|
||||
.is_some());
|
||||
}
|
||||
}
|
||||
238
src/strategy.rs
Normal file
238
src/strategy.rs
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
use crate::config::UpdateStrategy;
|
||||
use crate::version::VersionInfo;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub fn create_selector(strategy: &UpdateStrategy) -> Box<dyn VersionSelector> {
|
||||
match strategy {
|
||||
UpdateStrategy::Latest => Box::new(LatestVersionSelector),
|
||||
UpdateStrategy::LatestPatchOfPreviousMinor => Box::new(SmartPreviousMinorSelector),
|
||||
}
|
||||
}
|
||||
|
||||
pub trait VersionSelector {
|
||||
fn select_target_version(
|
||||
&self,
|
||||
available: &[VersionInfo],
|
||||
current_prefix: Option<String>,
|
||||
current_suffix: Option<String>,
|
||||
) -> Option<VersionInfo>;
|
||||
}
|
||||
|
||||
pub struct LatestVersionSelector;
|
||||
|
||||
impl VersionSelector for LatestVersionSelector {
|
||||
fn select_target_version(
|
||||
&self,
|
||||
available: &[VersionInfo],
|
||||
current_prefix: Option<String>,
|
||||
current_suffix: Option<String>,
|
||||
) -> Option<VersionInfo> {
|
||||
info!("Using update strategy: LatestVersionSelector");
|
||||
|
||||
let versions =
|
||||
get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix);
|
||||
|
||||
let latest = versions.first().cloned();
|
||||
|
||||
if let Some(ref selected) = latest {
|
||||
info!("Selected {} as latest version", selected.version);
|
||||
} else {
|
||||
warn!("No versions available for selection");
|
||||
}
|
||||
|
||||
latest
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SmartPreviousMinorSelector;
|
||||
|
||||
impl VersionSelector for SmartPreviousMinorSelector {
|
||||
fn select_target_version(
|
||||
&self,
|
||||
available: &[VersionInfo],
|
||||
current_prefix: Option<String>,
|
||||
current_suffix: Option<String>,
|
||||
) -> Option<VersionInfo> {
|
||||
info!("Using update strategy: SmartPreviousMinorSelector");
|
||||
|
||||
let versions =
|
||||
get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix);
|
||||
|
||||
let latest = &versions.first()?.version;
|
||||
info!("Latest version available: {}", latest);
|
||||
|
||||
let (target_major, max_minor) = if latest.minor == 0 {
|
||||
// If current minor is 0, look for the latest minor of the previous major
|
||||
if latest.major == 0 {
|
||||
info!("Cannot go to previous version of 0.0.x, skipping update");
|
||||
return None;
|
||||
}
|
||||
|
||||
info!("The latest version has a minor version of 0, looking for the latest minor of the previous major");
|
||||
(latest.major - 1, None)
|
||||
} else {
|
||||
info!("The latest version has a minor version of {}, looking for the latest patch of the previous minor", latest.minor);
|
||||
(latest.major, Some(latest.minor - 1))
|
||||
};
|
||||
|
||||
let selected = versions
|
||||
.iter()
|
||||
.find(|v| {
|
||||
v.version.major == target_major && max_minor.is_none_or(|m| v.version.minor <= m)
|
||||
})
|
||||
.cloned();
|
||||
|
||||
if let Some(ref selected) = selected {
|
||||
info!("Selected {} as latest version", selected.version);
|
||||
} else {
|
||||
warn!("No versions available for selection");
|
||||
}
|
||||
|
||||
selected
|
||||
}
|
||||
}
|
||||
|
||||
fn get_filtered_and_soreted_matching_versions(
|
||||
available: &[VersionInfo],
|
||||
current_prefix: Option<String>,
|
||||
current_suffix: Option<String>,
|
||||
) -> Vec<VersionInfo> {
|
||||
let mut sorted_versions = available.to_vec();
|
||||
sorted_versions.sort_by(|a, b| b.version.cmp(&a.version));
|
||||
|
||||
info!(
|
||||
"Available versions for selection: {}",
|
||||
sorted_versions
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
let filtered_versions: Vec<VersionInfo> = sorted_versions
|
||||
.into_iter()
|
||||
.filter(|v| v.prefix == current_prefix && v.suffix == current_suffix)
|
||||
.collect();
|
||||
|
||||
info!(
|
||||
"Filtered versions with matching prefix and suffix: {}",
|
||||
filtered_versions
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
filtered_versions
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use semver::Version;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_strategy_latest_patch_of_previous_minor() {
|
||||
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);
|
||||
|
||||
let available = vec![
|
||||
VersionInfo::from_tag("2.0.0").unwrap(),
|
||||
VersionInfo::from_tag("1.3.0").unwrap(),
|
||||
VersionInfo::from_tag("1.2.5").unwrap(),
|
||||
VersionInfo::from_tag("1.1.5").unwrap(),
|
||||
VersionInfo::from_tag("1.1.4").unwrap(),
|
||||
];
|
||||
|
||||
let target = selector.select_target_version(&available, None, None);
|
||||
assert_eq!(
|
||||
target.map(|v| v.version),
|
||||
Some(Version::parse("1.3.0").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_strategy_latest_patch() {
|
||||
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);
|
||||
|
||||
let available = vec![
|
||||
VersionInfo::from_tag("1.3.0").unwrap(),
|
||||
VersionInfo::from_tag("1.2.5").unwrap(),
|
||||
VersionInfo::from_tag("1.1.5").unwrap(),
|
||||
];
|
||||
|
||||
let target = selector.select_target_version(&available, None, None);
|
||||
assert_eq!(
|
||||
target.map(|v| v.version),
|
||||
Some(Version::parse("1.2.5").unwrap())
|
||||
);
|
||||
|
||||
let available_far_ahead = vec![
|
||||
VersionInfo::from_tag("2.0.0").unwrap(),
|
||||
VersionInfo::from_tag("1.4.0").unwrap(),
|
||||
VersionInfo::from_tag("1.2.5").unwrap(),
|
||||
VersionInfo::from_tag("1.1.5").unwrap(),
|
||||
];
|
||||
|
||||
let target = selector.select_target_version(&available_far_ahead, None, None);
|
||||
assert_eq!(
|
||||
target.map(|v| v.version),
|
||||
Some(Version::parse("1.4.0").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_strategy_latest() {
|
||||
let selector = create_selector(&UpdateStrategy::Latest);
|
||||
|
||||
let available = vec![
|
||||
VersionInfo::from_tag("2.0.0").unwrap(),
|
||||
VersionInfo::from_tag("1.2.5").unwrap(),
|
||||
VersionInfo::from_tag("1.1.5").unwrap(),
|
||||
];
|
||||
|
||||
let target = selector.select_target_version(&available, None, None);
|
||||
assert_eq!(
|
||||
target.map(|v| v.version),
|
||||
Some(Version::parse("2.0.0").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_and_suffix_matching_in_strategy() {
|
||||
let selector = create_selector(&UpdateStrategy::Latest);
|
||||
|
||||
let current_prefix = Some("v".to_string());
|
||||
let current_suffix = Some("-alpine".to_string());
|
||||
|
||||
let available = vec![
|
||||
VersionInfo::from_tag("1.3.0-alpine").unwrap(),
|
||||
VersionInfo::from_tag("v1.4.0").unwrap(),
|
||||
VersionInfo::from_tag("v1.5.0-alpine").unwrap(),
|
||||
VersionInfo::from_tag("2.0.0").unwrap(),
|
||||
];
|
||||
|
||||
let target = selector.select_target_version(&available, current_prefix, current_suffix);
|
||||
assert_eq!(
|
||||
target.map(|v| v.version),
|
||||
Some(Version::parse("1.5.0").unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cross_major_version_handling() {
|
||||
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);
|
||||
|
||||
let current_suffix = Some("-fat".to_string());
|
||||
|
||||
let available = vec![
|
||||
VersionInfo::from_tag("1.0.2-fat").unwrap(),
|
||||
VersionInfo::from_tag("0.46.2-fat").unwrap(),
|
||||
VersionInfo::from_tag("0.46.1-fat").unwrap(),
|
||||
VersionInfo::from_tag("0.45.6-fat").unwrap(),
|
||||
];
|
||||
|
||||
let target = selector.select_target_version(&available, None, current_suffix);
|
||||
assert!(target.is_some());
|
||||
assert_eq!(target.unwrap().version, Version::parse("0.46.2").unwrap());
|
||||
}
|
||||
}
|
||||
187
src/version.rs
Normal file
187
src/version.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
use regex::Regex;
|
||||
use semver::Version;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct VersionInfo {
|
||||
pub version: Version,
|
||||
pub prefix: Option<String>,
|
||||
pub suffix: Option<String>,
|
||||
pub original: String,
|
||||
}
|
||||
|
||||
impl VersionInfo {
|
||||
/// Parses a container image tag into a VersionInfo struct
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use docker_compose_updater::version::VersionInfo;
|
||||
///
|
||||
/// let version_info = VersionInfo::from_tag("v1.29.0-alpine-slim").unwrap();
|
||||
/// assert_eq!(version_info.version.to_string(), "1.29.0");
|
||||
/// assert_eq!(version_info.prefix, Some("v".to_string()));
|
||||
/// assert_eq!(version_info.suffix, Some("-alpine-slim".to_string()));
|
||||
/// ```
|
||||
pub fn from_tag(tag: &str) -> Option<Self> {
|
||||
let re = Regex::new(r"^(?P<prefix>.*?)(?P<version>(?:\d+\.)+\d+)(?P<suffix>.*?)$").unwrap();
|
||||
|
||||
if let Some(captures) = re.captures(tag) {
|
||||
let prefix_part = captures.name("prefix").map_or("", |m| m.as_str());
|
||||
let version_part = captures.name("version").map_or("", |m| m.as_str());
|
||||
let suffix_part = captures.name("suffix").map_or("", |m| m.as_str());
|
||||
|
||||
let version_part = version_part
|
||||
.split('.')
|
||||
.chain(std::iter::repeat("0"))
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
|
||||
if let Ok(version) = Version::parse(&version_part) {
|
||||
let prefix = if prefix_part.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(prefix_part.to_string())
|
||||
};
|
||||
|
||||
let suffix = if suffix_part.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(suffix_part.to_string())
|
||||
};
|
||||
|
||||
return Some(Self {
|
||||
version,
|
||||
prefix,
|
||||
suffix,
|
||||
original: tag.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Version> for VersionInfo {
|
||||
fn eq(&self, other: &Version) -> bool {
|
||||
self.version == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for VersionInfo {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.original)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts semantic version, prefix and suffix from a container image tag
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use docker_compose_updater::version::parse_version_tag;
|
||||
///
|
||||
/// let (version, prefix, suffix, original) = parse_version_tag("v1.29.0-alpine-slim");
|
||||
/// assert_eq!(version.unwrap().to_string(), "1.29.0");
|
||||
/// assert_eq!(prefix, Some("v".to_string()));
|
||||
/// assert_eq!(suffix, Some("-alpine-slim".to_string()));
|
||||
/// assert_eq!(original, "v1.29.0-alpine-slim".to_string());
|
||||
/// ```
|
||||
pub fn parse_version_tag(tag: &str) -> (Option<Version>, Option<String>, Option<String>, String) {
|
||||
if let Some(version_info) = VersionInfo::from_tag(tag) {
|
||||
(
|
||||
Some(version_info.version),
|
||||
version_info.prefix,
|
||||
version_info.suffix,
|
||||
version_info.original,
|
||||
)
|
||||
} else {
|
||||
(None, None, None, tag.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_tag() {
|
||||
let test_cases = vec![
|
||||
("1.29.0", "1.29.0", None, None),
|
||||
("v1.29.0", "1.29.0", Some("v"), None),
|
||||
("v1.29.0.10.2", "1.29.0", Some("v"), None),
|
||||
("v1.29", "1.29.0", Some("v"), None),
|
||||
("release-1.2.3", "1.2.3", Some("release-"), None),
|
||||
("app_v1.5.0", "1.5.0", Some("app_v"), None),
|
||||
("1.29.0-alpine", "1.29.0", None, Some("-alpine")),
|
||||
("15.3-alpine3.18", "15.3.0", None, Some("-alpine3.18")),
|
||||
(
|
||||
"v1.29.0-alpine-slim",
|
||||
"1.29.0",
|
||||
Some("v"),
|
||||
Some("-alpine-slim"),
|
||||
),
|
||||
(
|
||||
"release-1.2.3-ubuntu",
|
||||
"1.2.3",
|
||||
Some("release-"),
|
||||
Some("-ubuntu"),
|
||||
),
|
||||
("v4.1.1", "4.1.1", Some("v"), None),
|
||||
(
|
||||
"v2.1.3-bookworm-perl",
|
||||
"2.1.3",
|
||||
Some("v"),
|
||||
Some("-bookworm-perl"),
|
||||
),
|
||||
(
|
||||
"build123-2.0.1-final",
|
||||
"2.0.1",
|
||||
Some("build123-"),
|
||||
Some("-final"),
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected_version, expected_prefix, expected_suffix) in test_cases {
|
||||
let (version_opt, prefix, suffix, _) = parse_version_tag(input);
|
||||
|
||||
match version_opt {
|
||||
Some(version) => {
|
||||
assert_eq!(
|
||||
version.to_string(),
|
||||
expected_version,
|
||||
"Version mismatch for {input}"
|
||||
);
|
||||
assert_eq!(
|
||||
prefix,
|
||||
expected_prefix.map(String::from),
|
||||
"Prefix mismatch for {input}"
|
||||
);
|
||||
assert_eq!(
|
||||
suffix,
|
||||
expected_suffix.map(String::from),
|
||||
"Suffix mismatch for {input}"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
panic!("Expected version for {input}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_semver_tags() {
|
||||
let non_semver_tags = vec!["latest", "stable", "main", "alpine"];
|
||||
|
||||
for tag in non_semver_tags {
|
||||
let (version_opt, _prefix, _suffix, _) = parse_version_tag(tag);
|
||||
assert!(
|
||||
version_opt.is_none(),
|
||||
"Expected no version for non-semver tag: {tag}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue