Clean up
Some checks failed
Build and Publish Docker Image / test (push) Failing after 7s
Build and Publish Docker Image / build-and-push (push) Has been skipped
Build and Publish Docker Image / security-scan (push) Has been skipped

This commit is contained in:
Andras Schmelczer 2026-06-06 19:41:55 +01:00
parent 3f60b72c3b
commit b414c05400
18 changed files with 905 additions and 985 deletions

View file

@ -0,0 +1,78 @@
name: Build and Publish Docker Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
env:
REGISTRY: ${{ forgejo.server_url }}
IMAGE_NAME: ${{ forgejo.repository }}
jobs:
test:
runs-on: docker
container:
image: rust:1.88
steps:
- name: Checkout repository
uses: https://code.forgejo.org/actions/checkout@v4
- name: Install components
run: rustup component add rustfmt clippy
- name: Cache cargo registry
uses: https://code.forgejo.org/actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Run tests
run: cargo test --verbose
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check formatting
run: cargo fmt -- --check
build-and-push:
needs: test
runs-on: docker
steps:
- name: Checkout repository
uses: https://code.forgejo.org/actions/checkout@v4
- name: Build and publish image
uses: http://forgejo:3000/andras/ci-actions/docker-publish@main
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
token: ${{ secrets.FORGEJO_TOKEN }}
security-scan:
needs: build-and-push
runs-on: docker
if: github.event_name != 'pull_request'
steps:
- name: Resolve registry host
id: registry
run: |
host="$(echo '${{ github.server_url }}' | sed -E 's|https?://||; s|/$||')"
[ "$host" = "forgejo:3000" ] && host="127.0.0.1:13000"
echo "image=${host}/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
- name: Run Trivy vulnerability scanner
# Pinned to the commit behind trivy-action v0.36.0 for a reproducible,
# supply-chain-safe reference instead of the moving @master branch.
uses: https://github.com/aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
with:
image-ref: ${{ steps.registry.outputs.image }}:latest
format: "table"
exit-code: "1"
severity: "CRITICAL,HIGH"

View file

@ -1,126 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: 1.88
override: true
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Run tests
run: GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}"cargo test --verbose
- name: Run clippy
run: cargo clippy -- -D warnings
- name: Check formatting
run: cargo fmt -- --check
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
version: ${{ steps.meta.outputs.version }}
tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Test Docker image
if: github.event_name != 'pull_request'
run: |
docker run --rm \
-e RUST_LOG=info \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \
docker-compose-updater check
security-scan:
needs: build-and-push
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.version }}
format: "sarif"
output: "trivy-results.sarif"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: "trivy-results.sarif"

19
Cargo.lock generated
View file

@ -97,17 +97,6 @@ version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "async-trait"
version = "0.1.88"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -277,7 +266,6 @@ name = "docker-compose-updater"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"chrono", "chrono",
"clap", "clap",
"cron", "cron",
@ -291,6 +279,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"urlencoding",
] ]
[[package]] [[package]]
@ -1583,6 +1572,12 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"

View file

@ -17,7 +17,7 @@ anyhow = "1.0"
regex = "1.10" regex = "1.10"
cron = "0.12" cron = "0.12"
clap = { version = "4.0", features = ["derive"] } clap = { version = "4.0", features = ["derive"] }
async-trait = "0.1" urlencoding = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
chrono = "0.4" chrono = "0.4"

View file

@ -7,39 +7,24 @@ WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY src ./src COPY src ./src
# Build the application
RUN cargo build --release RUN cargo build --release
# Runtime stage FROM alpine:3.21
FROM alpine:latest
# Install runtime dependencies RUN apk add --no-cache ca-certificates tzdata curl
RUN apk add --no-cache \
ca-certificates \
tzdata \
curl
# Create non-root user
RUN addgroup -g 1000 updater && \ RUN addgroup -g 1000 updater && \
adduser -u 1000 -G updater -s /bin/sh -D updater adduser -u 1000 -G updater -s /bin/sh -D updater
# Create necessary directories
RUN mkdir -p /app/config && \ RUN mkdir -p /app/config && \
chown -R updater:updater /app chown -R updater:updater /app
# Copy binary from builder stage
COPY --from=builder /app/target/release/docker-compose-updater /usr/local/bin/docker-compose-updater COPY --from=builder /app/target/release/docker-compose-updater /usr/local/bin/docker-compose-updater
# Switch to non-root user
USER updater USER updater
# Set working directory
WORKDIR /app WORKDIR /app
# Expose health check port
EXPOSE 8080 EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1 CMD curl -f http://localhost:8080/health || exit 1

View file

@ -33,7 +33,7 @@ registries:
# auth_token: "${CUSTOM_REGISTRY_TOKEN}" # auth_token: "${CUSTOM_REGISTRY_TOKEN}"
# Update strategy # Update strategy
# Options: LatestPatchOfPreviousMinor (default), LatestPatchOfPreviousMinor, Latest # Options: LatestPatchOfPreviousMinor (default), Latest
update_strategy: "LatestPatchOfPreviousMinor" update_strategy: "LatestPatchOfPreviousMinor"
# Images to ignore (substring matching) # Images to ignore (substring matching)

View file

@ -1,5 +1,3 @@
version: '3.8'
services: services:
docker-compose-updater: docker-compose-updater:
image: ghcr.io/your-username/docker-compose-updater:latest image: ghcr.io/your-username/docker-compose-updater:latest
@ -9,36 +7,21 @@ services:
environment: environment:
- GITHUB_TOKEN=${GITHUB_TOKEN} - GITHUB_TOKEN=${GITHUB_TOKEN}
- GITLAB_TOKEN=${GITLAB_TOKEN} - GITLAB_TOKEN=${GITLAB_TOKEN}
- RUST_LOG=info
- TZ=UTC - TZ=UTC
volumes: volumes:
# Mount Docker socket for container management
- /var/run/docker.sock:/var/run/docker.sock:ro
# Mount directory containing your compose files # Mount directory containing your compose files
- ./compose-files:/app/compose-files:rw - ./compose-files:/app/compose-files:rw
# Mount configuration # Mount configuration
- ./config.yaml:/app/config/config.yaml:ro - ./config.yaml:/app/config/config.yaml:ro
# Optional: Mount additional compose files
- ./docker-compose.yml:/app/docker-compose.yml:rw
ports: ports:
- "8080:8080" - "8080:8080"
networks:
- updater-network
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
networks:
updater-network:
driver: bridge

View file

@ -2,8 +2,13 @@ use crate::registry::ImageRef;
use anyhow::Result; use anyhow::Result;
use regex::Regex; use regex::Regex;
use std::fs; use std::fs;
use std::sync::LazyLock;
use tracing::{info, warn}; use tracing::{info, warn};
static IMAGE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"^\s*image:\s*(?:["']([^"']+)["']|([^\s#]+))\s*(#.*)?$"#).unwrap()
});
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ServiceImage { pub struct ServiceImage {
pub service_name: String, pub service_name: String,
@ -27,7 +32,6 @@ impl ComposeParser {
pub fn parse_file(&self, file_path: &str) -> Result<ComposeFile> { pub fn parse_file(&self, file_path: &str) -> Result<ComposeFile> {
let content = fs::read_to_string(file_path)?; let content = fs::read_to_string(file_path)?;
let services = self.extract_services(&content)?; let services = self.extract_services(&content)?;
Ok(ComposeFile { content, services }) Ok(ComposeFile { content, services })
} }
@ -36,31 +40,65 @@ impl ComposeParser {
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
let mut in_services = false; let mut in_services = false;
let mut current_service: Option<String> = None; let mut current_service: Option<String> = None;
let image_regex = Regex::new(r#"^\s*image:\s*(?:["']([^"']+)["']|([^\s#]+))\s*(#.*)?$"#)?; let mut service_indent: Option<usize> = None;
let mut service_child_indent: Option<usize> = None;
for (line_number, line) in lines.iter().enumerate() { for (line_number, line) in lines.iter().enumerate() {
if line.trim_start().starts_with("services:") { let trimmed = line.trim_start();
in_services = true;
if trimmed.is_empty() || trimmed.starts_with('#') {
continue; continue;
} }
if in_services let indent = line.len() - trimmed.len();
&& line.chars().next().is_some_and(|c| c.is_alphabetic())
&& !line.starts_with(" ") if indent == 0 && trimmed.starts_with("services:") {
{ in_services = true;
current_service = None;
service_indent = None;
service_child_indent = None;
continue;
}
if in_services && indent == 0 {
in_services = false; in_services = false;
current_service = None; current_service = None;
service_indent = None;
service_child_indent = None;
continue; continue;
} }
if in_services { if !in_services {
if let Some(service_name) = self.extract_service_name(line) { continue;
current_service = Some(service_name); }
if (service_indent.is_none() || indent == service_indent.unwrap())
&& trimmed.ends_with(':')
&& !trimmed.contains(' ')
&& trimmed
.trim_end_matches(':')
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
{
let name = trimmed.trim_end_matches(':');
if !name.is_empty() {
service_indent = Some(indent);
service_child_indent = None;
current_service = Some(name.to_string());
}
continue; continue;
} }
if let Some(ref service_name) = current_service { if let Some(ref service_name) = current_service {
if let Some(image_ref) = self.extract_image_from_line(line, &image_regex)? { if let Some(si) = service_indent {
if indent > si {
// The first key under a service fixes the direct-child
// indent; only keys at that exact level are the service's
// own properties. This avoids picking up an `image:` nested
// under `build:` or other deeper mappings.
let child_indent = *service_child_indent.get_or_insert(indent);
if indent == child_indent {
if let Some(image_ref) = self.extract_image_from_line(line)? {
info!( info!(
"Found service '{}' with image '{}'", "Found service '{}' with image '{}'",
service_name, image_ref service_name, image_ref
@ -75,51 +113,34 @@ impl ComposeParser {
} }
} }
} }
}
}
Ok(services) Ok(services)
} }
fn extract_service_name(&self, line: &str) -> Option<String> { fn extract_image_from_line(&self, line: &str) -> Result<Option<ImageRef>> {
let trimmed = line.trim_start(); let trimmed = line.trim_start();
let indent_level = line.len() - trimmed.len(); if !trimmed.starts_with("image:") {
return Ok(None);
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 let Some(captures) = IMAGE_REGEX.captures(line) else {
} return Ok(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 let image_str = captures
.get(1) .get(1)
.or_else(|| captures.get(2)) .or_else(|| captures.get(2))
.unwrap() .unwrap()
.as_str(); .as_str();
if image_str.find('@').is_some() { if image_str.contains('@') {
info!("Found digest in image string, skipping: {}", image_str); info!("Skipping digest-pinned image: {}", image_str);
return Ok(None); return Ok(None);
} }
if image_str.trim().is_empty() if image_str.trim().is_empty() {
|| image_str.trim() == "\"\""
|| image_str.trim() == "''"
{
info!("Empty image string found in line: {}, skipping", line);
return Ok(None); return Ok(None);
} }
@ -130,12 +151,6 @@ impl ComposeParser {
Ok(None) Ok(None)
} }
} }
} else {
Ok(None)
}
} else {
Ok(None)
}
} }
} }
@ -183,4 +198,52 @@ services:
assert_eq!(result.services[0].image_ref.name, "nginx"); assert_eq!(result.services[0].image_ref.name, "nginx");
assert_eq!(result.services[0].image_ref.tag, "1.21.0"); assert_eq!(result.services[0].image_ref.tag, "1.21.0");
} }
#[test]
fn test_service_names_with_dots() {
let compose_content = r#"
services:
my.service:
image: nginx:1.21.0
another-service:
image: redis:7.0
"#;
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(), 2);
assert_eq!(result.services[0].service_name, "my.service");
assert_eq!(result.services[1].service_name, "another-service");
}
#[test]
fn test_ignores_image_nested_under_build() {
let compose_content = r#"
services:
web:
build:
context: .
image: should-not-match:9.9.9
image: nginx:1.21.0
"#;
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(), 1);
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");
}
} }

View file

@ -1,38 +1,49 @@
use super::parser::{ComposeFile, ComposeParser, ServiceImage}; use super::parser::{ComposeFile, ComposeParser, ServiceImage};
use crate::config::Config; use crate::config::{Config, UpdateStrategy};
use crate::registry::Client as RegistryClient; use crate::registry::{Client as RegistryClient, ImageRef};
use crate::strategy::create_selector; use crate::strategy::create_selector;
use crate::version::parse_version_tag; use crate::version::{parse_version_tag, VersionInfo};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use regex::Regex; use regex::Regex;
use semver::Version;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
static IMAGE_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"^(\s*image:\s*)(?:["']([^"']+)["']|([^\s#]+))(\s*(?:#.*)?)$"#).unwrap()
});
pub struct ComposeUpdater { pub struct ComposeUpdater {
config: Config, config: Config,
registry_client: RegistryClient, registry_client: RegistryClient,
parser: ComposeParser, parser: ComposeParser,
} }
pub struct UpdateReport {
pub files_found: usize,
pub updated_files: Vec<String>,
}
impl ComposeUpdater { impl ComposeUpdater {
pub fn new(config: Config) -> Self { pub fn new(config: Config) -> Self {
let registry_client = RegistryClient::new(config.clone()); let registry_client = RegistryClient::new(config.clone());
let parser = ComposeParser::new();
Self { Self {
config, config,
registry_client, registry_client,
parser, parser: ComposeParser::new(),
} }
} }
pub async fn update_all_compose_files(&self) -> Result<Vec<String>> { pub async fn update_all_compose_files(&self) -> Result<UpdateReport> {
let mut updated_files = Vec::new(); let mut updated_files = Vec::new();
let mut files_found = 0;
for compose_path in &self.config.compose_paths { for compose_path in &self.config.compose_paths {
let compose_files = self.find_compose_files(compose_path)?; let compose_files = self.find_compose_files(compose_path)?;
files_found += compose_files.len();
for file_path in compose_files { for file_path in compose_files {
let updated = self.update_compose_file(&file_path).await?; let updated = self.update_compose_file(&file_path).await?;
@ -42,7 +53,10 @@ impl ComposeUpdater {
} }
} }
Ok(updated_files) Ok(UpdateReport {
files_found,
updated_files,
})
} }
pub fn parse_compose_file(&self, file_path: &str) -> Result<ComposeFile> { pub fn parse_compose_file(&self, file_path: &str) -> Result<ComposeFile> {
@ -58,7 +72,7 @@ impl ComposeUpdater {
for service in &compose_file.services { for service in &compose_file.services {
if self.config.is_image_ignored(&service.image_ref.to_string()) { if self.config.is_image_ignored(&service.image_ref.to_string()) {
info!("Skipping ignored image: {}", service.image_ref.to_string()); debug!("Skipping ignored image: {}", service.image_ref);
continue; continue;
} }
@ -69,16 +83,13 @@ impl ComposeUpdater {
updated = true; updated = true;
info!( info!(
"Updated {}: {} -> {}", "Updated {}: {} -> {}",
service.service_name, service.service_name, service.image_ref, new_image
service.image_ref.to_string(),
new_image
); );
} }
Ok(None) => { Ok(None) => {
debug!( debug!(
"No update needed for {}: {}", "No update needed for {}: {}",
service.service_name, service.service_name, service.image_ref
service.image_ref.to_string()
); );
} }
Err(e) => { Err(e) => {
@ -97,57 +108,45 @@ impl ComposeUpdater {
} }
fn write_updated_content(&self, file_path: &str, content: String) -> Result<()> { fn write_updated_content(&self, file_path: &str, content: String) -> Result<()> {
if !self.config.dry_run { if self.config.dry_run {
info!("Dry run: would update {}", file_path);
} else {
fs::write(file_path, content)?; fs::write(file_path, content)?;
info!("Updated compose file: {}", file_path); info!("Updated compose file: {}", file_path);
} else {
info!("Dry run: Would update compose file: {}", file_path);
} }
Ok(()) Ok(())
} }
async fn update_service_image(&self, service: &ServiceImage) -> Result<Option<String>> { 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, _) = let (current_version, current_prefix, current_suffix, _) =
parse_version_tag(&service.image_ref.tag); parse_version_tag(&service.image_ref.tag);
let Some(current_version) = current_version else {
debug!(
"Skipping non-semver tag '{}' for service {}",
service.image_ref.tag, service.service_name
);
return Ok(None);
};
let available_versions = self let available_versions = self
.registry_client .registry_client
.get_available_versions(&service.image_ref) .get_available_versions(&service.image_ref)
.await?; .await?;
if available_versions.is_empty() { if available_versions.is_empty() {
warn!("No versions available for selection"); warn!("No versions available for {}", service.image_ref);
return Ok(None); return Ok(None);
} }
let selector = create_selector(&self.config.update_strategy); Ok(choose_new_tag(
if let Some(target_version_info) = &service.image_ref,
selector.select_target_version(&available_versions, current_prefix, current_suffix) &current_version,
{ &available_versions,
// Only update if the target version is different AND higher than the current version current_prefix,
// This prevents downgrades current_suffix,
if Some(target_version_info.version.clone()) != current_version { &self.config.update_strategy,
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( pub fn replace_image_in_content(
@ -156,19 +155,18 @@ impl ComposeUpdater {
service: &ServiceImage, service: &ServiceImage,
new_image: &str, new_image: &str,
) -> Result<String> { ) -> Result<String> {
let image_regex = let Some(captures) = IMAGE_LINE_REGEX.captures(&service.original_line) else {
Regex::new(r#"^(\s*image:\s*)(?:["']([^"']+)["']|([^\s#]+))(\s*(?:#.*)?)$"#)?; return Err(anyhow!(
"Could not parse image line: {}",
service.original_line
));
};
if let Some(captures) = image_regex.captures(&service.original_line) {
let prefix = captures.get(1).unwrap().as_str(); 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 suffix = captures.get(4).unwrap().as_str();
let was_quoted = captures.get(2).is_some();
let image_part = if captures.get(2).is_some() { let image_part = if was_quoted {
format!("\"{new_image}\"") format!("\"{new_image}\"")
} else { } else {
new_image.to_string() new_image.to_string()
@ -177,8 +175,7 @@ impl ComposeUpdater {
let new_line = format!("{prefix}{image_part}{suffix}"); let new_line = format!("{prefix}{image_part}{suffix}");
let lines: Vec<&str> = content.lines().collect(); let lines: Vec<&str> = content.lines().collect();
if service.line_number < lines.len() if service.line_number < lines.len() && lines[service.line_number] == service.original_line
&& lines[service.line_number] == service.original_line
{ {
let mut result_lines = lines; let mut result_lines = lines;
result_lines[service.line_number] = &new_line; result_lines[service.line_number] = &new_line;
@ -191,14 +188,11 @@ impl ComposeUpdater {
Ok(result) Ok(result)
} else { } else {
Err(anyhow!( Err(anyhow!(
"Line number mismatch or content changed for service: {}", "Line mismatch for service '{}': line {} expected '{}', got '{}'",
service.service_name service.service_name,
)) service.line_number,
} service.original_line,
} else { lines.get(service.line_number).unwrap_or(&"<out of bounds>")
Err(anyhow!(
"Could not parse image line: {}",
service.original_line
)) ))
} }
} }
@ -256,38 +250,111 @@ impl ComposeUpdater {
} }
} }
/// Chooses the new image string for `image_ref`, or `None` when no suitable
/// upgrade exists. The selected version must share the current tag's prefix and
/// suffix and be strictly higher than `current_version`, which prevents both
/// downgrades and no-op rewrites to the same version.
fn choose_new_tag(
image_ref: &ImageRef,
current_version: &Version,
available_versions: &[VersionInfo],
current_prefix: Option<String>,
current_suffix: Option<String>,
strategy: &UpdateStrategy,
) -> Option<String> {
let selector = create_selector(strategy);
let target =
selector.select_target_version(available_versions, current_prefix, current_suffix)?;
if target.version <= *current_version {
return None;
}
let mut new_image_ref = image_ref.clone();
new_image_ref.tag = target.to_string();
Some(new_image_ref.to_string())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{Config, UpdateStrategy};
use std::io::Write;
use tempfile::NamedTempFile;
#[tokio::test] fn version_infos(tags: &[&str]) -> Vec<VersionInfo> {
async fn test_prevents_downgrade() { tags.iter()
let mut config = Config::default(); .map(|t| VersionInfo::from_tag(t).unwrap())
config.update_strategy = UpdateStrategy::Latest; .collect()
config.dry_run = true; }
let updater = ComposeUpdater::new(config); #[test]
fn test_choose_new_tag_upgrades_to_higher_version() {
let image = ImageRef::parse("nginx:1.25.0").unwrap();
let current = Version::parse("1.25.0").unwrap();
let available = version_infos(&["1.25.0", "1.26.0", "1.24.0"]);
// Create a temporary compose file with a higher version let result = choose_new_tag(
let mut temp_file = NamedTempFile::new().unwrap(); &image,
writeln!(temp_file, "services:\n web:\n image: nginx:1.25.0").unwrap(); &current,
&available,
None,
None,
&UpdateStrategy::Latest,
);
assert_eq!(result, Some("nginx:1.26.0".to_string()));
}
let file_path = temp_file.path().to_str().unwrap(); #[test]
let compose_file = updater.parse_compose_file(file_path).unwrap(); fn test_choose_new_tag_prevents_downgrade() {
let image = ImageRef::parse("nginx:1.25.0").unwrap();
let current = Version::parse("1.25.0").unwrap();
// Registry only offers older versions.
let available = version_infos(&["1.24.0", "1.23.5"]);
assert_eq!(compose_file.services.len(), 1); let result = choose_new_tag(
let service = &compose_file.services[0]; &image,
&current,
&available,
None,
None,
&UpdateStrategy::Latest,
);
assert!(result.is_none(), "should never downgrade");
}
// Mock a scenario where the strategy selects a lower version #[test]
// This would happen if available versions only include older versions fn test_choose_new_tag_skips_equal_version() {
let (current_version, _, _, _) = parse_version_tag(&service.image_ref.tag); let image = ImageRef::parse("nginx:1.25.0").unwrap();
assert!(current_version.is_some()); let current = Version::parse("1.25.0").unwrap();
let available = version_infos(&["1.25.0"]);
// The actual test would need mocked registry responses, but we can verify let result = choose_new_tag(
// the logic by checking that current version is properly extracted &image,
assert_eq!(current_version.unwrap().to_string(), "1.25.0"); &current,
&available,
None,
None,
&UpdateStrategy::Latest,
);
assert!(result.is_none(), "no rewrite when already on the latest");
}
#[test]
fn test_choose_new_tag_respects_prefix_and_suffix() {
let image = ImageRef::parse("nginx:v1.25.0-alpine").unwrap();
let current = Version::parse("1.25.0").unwrap();
let available = vec![
VersionInfo::from_tag("v1.26.0-alpine").unwrap(),
VersionInfo::from_tag("1.27.0").unwrap(), // no matching prefix/suffix
VersionInfo::from_tag("v1.26.0").unwrap(), // missing suffix
];
let result = choose_new_tag(
&image,
&current,
&available,
Some("v".to_string()),
Some("-alpine".to_string()),
&UpdateStrategy::Latest,
);
assert_eq!(result, Some("nginx:v1.26.0-alpine".to_string()));
} }
} }

View file

@ -1,22 +1,22 @@
use anyhow::Result; use anyhow::Result;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use tracing::info; use std::sync::LazyLock;
use tracing::{info, warn};
static ENV_VAR_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)").unwrap());
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config { pub struct Config {
#[serde(default)]
pub compose_paths: Vec<PathBuf>, pub compose_paths: Vec<PathBuf>,
#[serde(default)]
pub schedule: String, pub schedule: String,
#[serde(default)]
pub registries: HashMap<String, RegistryConfig>, pub registries: HashMap<String, RegistryConfig>,
#[serde(default)]
pub update_strategy: UpdateStrategy, pub update_strategy: UpdateStrategy,
#[serde(default)]
pub ignore_images: Vec<String>, pub ignore_images: Vec<String>,
#[serde(default)]
pub dry_run: bool, pub dry_run: bool,
} }
@ -53,7 +53,7 @@ impl Default for Config {
Self { Self {
compose_paths: vec![PathBuf::from(".")], compose_paths: vec![PathBuf::from(".")],
schedule: "0 0 2 * * *".to_string(), // Daily at 2 AM schedule: "0 0 2 * * *".to_string(),
registries, registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor, update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec![], ignore_images: vec![],
@ -68,31 +68,32 @@ impl Config {
let content = std::fs::read_to_string(config_path)?; let content = std::fs::read_to_string(config_path)?;
let expanded_content = Self::expand_env_vars(&content); let expanded_content = Self::expand_env_vars(&content);
let mut config: Self = serde_yaml::from_str(&expanded_content)?; let mut config: Self = serde_yaml::from_str(&expanded_content)?;
config.resolve_env_tokens(); config.normalize_auth_tokens();
Ok(config) Ok(config)
} }
/// Expand environment variable placeholders like ${VAR} in the config content
pub fn expand_env_vars(content: &str) -> String { pub fn expand_env_vars(content: &str) -> String {
let env_var_pattern = regex::Regex::new(r"\$\{([^}]+)\}").unwrap(); ENV_VAR_REGEX
env_var_pattern
.replace_all(content, |caps: &regex::Captures| { .replace_all(content, |caps: &regex::Captures| {
let var_name = &caps[1]; let var_name = caps.get(1).or_else(|| caps.get(2)).unwrap().as_str();
std::env::var(var_name).unwrap_or_else(|_| format!("${{{var_name}}}")) std::env::var(var_name).unwrap_or_else(|_| {
warn!(
"Environment variable `{}` referenced in config is not set; substituting an empty string",
var_name
);
String::new()
}) })
.to_string() })
.into_owned()
} }
/// Resolve environment variable tokens after deserialization /// Treat a registry `auth_token` that expanded to an empty string (e.g. an
fn resolve_env_tokens(&mut self) { /// unset `${TOKEN}`) as no token at all, so we fall back to unauthenticated
for registry_config in self.registries.values_mut() { /// access with a clear error instead of attempting auth with an empty token.
if let Some(token) = &registry_config.auth_token { fn normalize_auth_tokens(&mut self) {
if token.starts_with("$") { for registry in self.registries.values_mut() {
// Handle direct env var references like "$GITHUB_TOKEN" if registry.auth_token.as_deref() == Some("") {
let env_var_name = token.trim_start_matches('$'); registry.auth_token = None;
registry_config.auth_token = std::env::var(env_var_name).ok();
}
} }
} }
} }
@ -100,6 +101,6 @@ impl Config {
pub fn is_image_ignored(&self, image: &str) -> bool { pub fn is_image_ignored(&self, image: &str) -> bool {
self.ignore_images self.ignore_images
.iter() .iter()
.any(|ignored| image.contains(ignored)) .any(|pattern| image.contains(pattern))
} }
} }

View file

@ -14,22 +14,18 @@ pub struct HealthHandle {
last_update_success: Arc<Mutex<bool>>, last_update_success: Arc<Mutex<bool>>,
} }
impl Default for HealthServer {
fn default() -> Self {
Self::new().0
}
}
impl HealthServer { impl HealthServer {
pub fn new() -> (Self, HealthHandle) { pub fn new() -> (Self, HealthHandle) {
let last_update_success = Arc::new(Mutex::new(true)); let last_update_success = Arc::new(Mutex::new(true));
let server = Self { let handle = HealthHandle {
last_update_success: last_update_success.clone(), last_update_success: last_update_success.clone(),
}; };
let handle = HealthHandle { (
Self {
last_update_success, last_update_success,
}; },
(server, handle) handle,
)
} }
pub async fn start(self) -> anyhow::Result<()> { pub async fn start(self) -> anyhow::Result<()> {
@ -37,48 +33,47 @@ impl HealthServer {
info!("Health server listening on port 8080"); info!("Health server listening on port 8080");
loop { loop {
let (mut socket, _) = listener.accept().await?; let (socket, _) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
warn!("Failed to accept connection: {}", e);
continue;
}
};
let health_status = self.last_update_success.clone(); let health_status = self.last_update_success.clone();
tokio::spawn(async move { tokio::spawn(async move {
// Set timeout for the entire request handling (5 seconds) let _ = timeout(Duration::from_secs(10), async {
let handle_request = async { handle_connection(socket, health_status).await;
})
.await;
});
}
}
}
async fn handle_connection(mut socket: tokio::net::TcpStream, health_status: Arc<Mutex<bool>>) {
let mut buffer = [0; 1024]; let mut buffer = [0; 1024];
// Read with timeout to prevent hanging connections
match timeout(Duration::from_secs(5), socket.read(&mut buffer)).await { match timeout(Duration::from_secs(5), socket.read(&mut buffer)).await {
Ok(Ok(_)) => { Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return,
// Successfully read request (we don't need to parse it for health check) Ok(Ok(_)) => {}
}
Ok(Err(_)) | Err(_) => {
warn!("Health check request read timeout or error");
return;
}
} }
// Safely access health status without panicking let is_healthy = health_status.lock().map(|s| *s).unwrap_or(false);
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 { let (status_line, json_body) = if is_healthy {
("HTTP/1.1 200 OK", "{\"status\":\"healthy\"}") ("HTTP/1.1 200 OK", r#"{"status":"healthy"}"#)
} else { } else {
( (
"HTTP/1.1 503 Service Unavailable", "HTTP/1.1 503 Service Unavailable",
"{\"status\":\"unhealthy\"}", r#"{"status":"unhealthy"}"#,
) )
}; };
let response = format!( let response = format!(
"{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", "{status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json_body}",
status_line,
json_body.len(), json_body.len(),
json_body
); );
let _ = timeout( let _ = timeout(
@ -86,13 +81,6 @@ impl HealthServer {
socket.write_all(response.as_bytes()), socket.write_all(response.as_bytes()),
) )
.await; .await;
};
// Overall timeout for the entire connection handling
let _ = timeout(Duration::from_secs(10), handle_request).await;
});
}
}
} }
impl HealthHandle { impl HealthHandle {
@ -103,12 +91,10 @@ impl HealthHandle {
} }
pub fn report_update_success(&self) { pub fn report_update_success(&self) {
info!("Update succeeded - marking health as healthy");
self.set_health_status(true); self.set_health_status(true);
} }
pub fn report_update_failure(&self) { pub fn report_update_failure(&self) {
info!("Update failed - marking health as unhealthy");
self.set_health_status(false); self.set_health_status(false);
} }
} }

View file

@ -4,13 +4,18 @@ use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use reqwest::{Client as HttpClient, Response, StatusCode}; use reqwest::{Client as HttpClient, Response, StatusCode};
use serde::Deserialize; use serde::Deserialize;
use std::sync::LazyLock;
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{debug, warn}; use tracing::{debug, warn};
const PAGE_SIZE: usize = 500; const PAGE_SIZE: usize = 500;
const MAX_PAGES: usize = 100;
const MAX_RETRY_ATTEMPTS: u32 = 5; const MAX_RETRY_ATTEMPTS: u32 = 5;
const MAX_AUTH_ATTEMPTS: u32 = 2;
const INITIAL_RETRY_DELAY_SECS: u64 = 1; const INITIAL_RETRY_DELAY_SECS: u64 = 1;
const HTTP_TIMEOUT_SECS: u64 = 30;
const HTTP_CONNECT_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ImageRef { pub struct ImageRef {
@ -49,7 +54,6 @@ impl ImageRef {
} }
} }
_ => { _ => {
// 3 or more parts: first is registry, last is name, everything in between is namespace
let registry = registry_parts[0].to_string(); let registry = registry_parts[0].to_string();
let name = registry_parts[registry_parts.len() - 1].to_string(); let name = registry_parts[registry_parts.len() - 1].to_string();
let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/")); let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/"));
@ -68,23 +72,20 @@ impl ImageRef {
impl std::fmt::Display for ImageRef { impl std::fmt::Display for ImageRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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" { if self.registry == "docker.io" {
match &self.namespace { match &self.namespace {
Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag), Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag),
None => write!(f, "{}:{}", self.name, self.tag), None => write!(f, "{}:{}", self.name, self.tag),
} }
} else { } else {
write!(f, "{}:{}", base, self.tag) match &self.namespace {
Some(ns) => write!(f, "{}/{}/{}:{}", self.registry, ns, self.name, self.tag),
None => write!(f, "{}/{}:{}", self.registry, self.name, self.tag),
}
} }
} }
} }
/// Docker Hub API response for tag list
#[derive(Deserialize)] #[derive(Deserialize)]
struct DockerHubTagsResponse { struct DockerHubTagsResponse {
results: Vec<DockerHubTag>, results: Vec<DockerHubTag>,
@ -96,8 +97,6 @@ struct DockerHubTag {
name: String, 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 { pub struct Client {
http_client: HttpClient, http_client: HttpClient,
config: Config, config: Config,
@ -105,21 +104,23 @@ pub struct Client {
impl Client { impl Client {
pub fn new(config: Config) -> Self { pub fn new(config: Config) -> Self {
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
.build()
.expect("failed to build HTTP client");
Self { Self {
http_client: HttpClient::new(), http_client,
config, 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> { fn parse_retry_after(&self, retry_after: &str) -> Option<Duration> {
// Try parsing as seconds first
if let Ok(seconds) = retry_after.parse::<u64>() { if let Ok(seconds) = retry_after.parse::<u64>() {
return Some(Duration::from_secs(seconds)); 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) { if let Ok(date) = DateTime::parse_from_rfc2822(retry_after) {
let now = Utc::now(); let now = Utc::now();
let wait_time = date.signed_duration_since(now); let wait_time = date.signed_duration_since(now);
@ -131,7 +132,6 @@ impl Client {
None None
} }
/// Perform HTTP request with retry logic for rate limiting (429)
async fn request_with_retry<F, Fut>( async fn request_with_retry<F, Fut>(
&self, &self,
request_fn: F, request_fn: F,
@ -158,7 +158,6 @@ impl Client {
)); ));
} }
// Check for Retry-After header
let wait_duration = if let Some(retry_after) = response let wait_duration = if let Some(retry_after) = response
.headers() .headers()
.get("retry-after") .get("retry-after")
@ -176,7 +175,6 @@ impl Client {
sleep(wait_duration).await; sleep(wait_duration).await;
// Exponential backoff for next attempt if no Retry-After header
delay = delay.saturating_mul(2); delay = delay.saturating_mul(2);
} else { } else {
return Ok(response); return Ok(response);
@ -194,15 +192,10 @@ impl Client {
fn build_repository_path(&self, image_ref: &ImageRef) -> String { fn build_repository_path(&self, image_ref: &ImageRef) -> String {
match &image_ref.namespace { match &image_ref.namespace {
Some(namespace) => format!("{}/{}", namespace, image_ref.name), Some(namespace) => format!("{}/{}", namespace, image_ref.name),
None => { None if image_ref.registry == "docker.io" => {
if image_ref.registry == "docker.io" {
// For Docker Hub official images, use 'library' namespace in public API
format!("library/{}", image_ref.name) format!("library/{}", image_ref.name)
} else {
// For other registries, no namespace means just the image name
image_ref.name.clone()
}
} }
None => image_ref.name.clone(),
} }
} }
@ -255,7 +248,7 @@ impl Client {
async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> { async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
let repo_path = self.build_repository_path(image_ref); let repo_path = self.build_repository_path(image_ref);
let mut results = Vec::new(); let mut results = Vec::new();
// Use Docker Hub's public REST API which doesn't require authentication for public repos let mut page_count: usize = 0;
let mut url = format!( let mut url = format!(
"https://hub.docker.com/v2/repositories/{repo_path}/tags/?page_size={PAGE_SIZE}" "https://hub.docker.com/v2/repositories/{repo_path}/tags/?page_size={PAGE_SIZE}"
); );
@ -286,6 +279,15 @@ impl Client {
results.extend(new_tags); results.extend(new_tags);
page_count += 1;
if page_count >= MAX_PAGES {
warn!(
"Reached maximum page count ({}) for {}, stopping pagination",
MAX_PAGES, image_ref
);
break;
}
if let Some(next) = next_page { if let Some(next) = next_page {
url = next; url = next;
} else { } else {
@ -304,12 +306,16 @@ impl Client {
let mut last_tag: Option<String> = None; let mut last_tag: Option<String> = None;
let mut next_url: Option<String> = None; let mut next_url: Option<String> = None;
let mut bearer_token: Option<String> = None; let mut bearer_token: Option<String> = None;
let mut auth_attempts: u32 = 0;
let mut page_count: usize = 0;
// Reference: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints
loop { loop {
// Use next URL from Link header if available, otherwise build URL with last parameter
let url = if let Some(next) = next_url.take() { let url = if let Some(next) = next_url.take() {
if next.starts_with("http://") || next.starts_with("https://") {
next
} else {
format!("{}{next}", registry_config.url) format!("{}{next}", registry_config.url)
}
} else { } else {
format!( format!(
"{}/v2/{repo_path}/tags/list?n={PAGE_SIZE}{}", "{}/v2/{repo_path}/tags/list?n={PAGE_SIZE}{}",
@ -324,7 +330,6 @@ impl Client {
debug!("Registry API URL: {}", url); debug!("Registry API URL: {}", url);
// Try unauthenticated request first to potentially get auth challenge
let url_clone = url.clone(); let url_clone = url.clone();
let bearer_token_clone = bearer_token.clone(); let bearer_token_clone = bearer_token.clone();
let response = self let response = self
@ -341,13 +346,21 @@ impl Client {
.await?; .await?;
let (new_tags, link_next) = if response.status() == reqwest::StatusCode::UNAUTHORIZED { let (new_tags, link_next) = if response.status() == reqwest::StatusCode::UNAUTHORIZED {
if auth_attempts >= MAX_AUTH_ATTEMPTS {
return Err(anyhow!(
"Authentication failed after {} attempts for registry {}",
auth_attempts,
image_ref.registry
));
}
if let Some(token) = &registry_config.auth_token { if let Some(token) = &registry_config.auth_token {
// Try Docker Registry v2 auth flow if we get auth challenge
if let Some(auth_header) = response.headers().get("www-authenticate") { if let Some(auth_header) = response.headers().get("www-authenticate") {
bearer_token = self let auth_str = auth_header.to_str().map_err(|e| {
.try_registry_v2_auth(auth_header.to_str().unwrap(), token) anyhow!("Invalid WWW-Authenticate header encoding: {}", e)
.await?; })?;
continue; // Retry with new token bearer_token = self.try_registry_v2_auth(auth_str, token).await?;
auth_attempts += 1;
continue;
} else { } else {
return Err(anyhow!( return Err(anyhow!(
"Unauthorized request but no WWW-Authenticate header found" "Unauthorized request but no WWW-Authenticate header found"
@ -357,7 +370,6 @@ impl Client {
return Err(anyhow!("Unauthorized request but no auth token configured")); return Err(anyhow!("Unauthorized request but no auth token configured"));
} }
} else if response.status().is_success() { } else if response.status().is_success() {
// Check for Link header for pagination
let link_next = response let link_next = response
.headers() .headers()
.get("link") .get("link")
@ -377,14 +389,26 @@ impl Client {
)); ));
}; };
page_count += 1;
if page_count >= MAX_PAGES {
warn!(
"Reached maximum page count ({}) for {}, stopping pagination",
MAX_PAGES, image_ref
);
results.extend(new_tags);
break;
}
let maybe_last_tag = new_tags.last().map(|v| v.original.clone()); let maybe_last_tag = new_tags.last().map(|v| v.original.clone());
results.extend(new_tags); results.extend(new_tags);
// Use Link header next URL if available, otherwise fall back to last parameter pagination
if let Some(next) = link_next { if let Some(next) = link_next {
next_url = Some(next); next_url = Some(next);
} else if let Some(last) = maybe_last_tag { } else if let Some(ref last) = maybe_last_tag {
last_tag = Some(last); if last_tag.as_ref() == Some(last) {
break;
}
last_tag = maybe_last_tag;
} else { } else {
break; break;
} }
@ -394,13 +418,15 @@ impl Client {
} }
async fn try_registry_v2_auth(&self, auth_str: &str, token: &str) -> Result<Option<String>> { 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 = extract_auth_param(auth_str, "realm")?;
let realm = self.extract_auth_param(auth_str, "realm")?; let service = extract_auth_param(auth_str, "service")?;
let service = self.extract_auth_param(auth_str, "service")?; let scope = extract_auth_param(auth_str, "scope")?;
let scope = self.extract_auth_param(auth_str, "scope")?;
// Request token from auth endpoint let auth_url = format!(
let auth_url = format!("{realm}?service={service}&scope={scope}"); "{realm}?service={}&scope={}",
urlencoding::encode(&service),
urlencoding::encode(&scope),
);
debug!("Getting registry token from: {}", auth_url); debug!("Getting registry token from: {}", auth_url);
let auth_url_clone = auth_url.clone(); let auth_url_clone = auth_url.clone();
@ -432,29 +458,16 @@ impl Client {
return Ok(Some(registry_token.to_string())); return Ok(Some(registry_token.to_string()));
} }
Err(anyhow!("Auth flow didn't work, caller can try fallback")) Err(anyhow!("Auth response missing 'token' field"))
}
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> { 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(',') { for link in link_header.split(',') {
let parts: Vec<&str> = link.trim().split(';').collect(); let parts: Vec<&str> = link.trim().split(';').collect();
if parts.len() >= 2 { if parts.len() >= 2 {
let url = parts[0].trim(); let url = parts[0].trim();
if url.starts_with('<') && url.ends_with('>') { if url.starts_with('<') && url.ends_with('>') {
let url = &url[1..url.len() - 1]; // Remove < and > let url = &url[1..url.len() - 1];
for param in &parts[1..] { for param in &parts[1..] {
let param = param.trim(); let param = param.trim();
if param == "rel=\"next\"" || param == "rel=next" { if param == "rel=\"next\"" || param == "rel=next" {
@ -468,6 +481,22 @@ impl Client {
} }
} }
static AUTH_PARAM_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(\w+)="([^"]+)""#).unwrap());
fn extract_auth_param(auth_str: &str, param: &str) -> Result<String> {
for caps in AUTH_PARAM_REGEX.captures_iter(auth_str) {
if caps.get(1).map(|m| m.as_str()) == Some(param) {
return Ok(caps[2].to_string());
}
}
Err(anyhow!(
"Missing '{}' in auth challenge: {}",
param,
auth_str
))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -5,8 +5,7 @@ use cron::Schedule;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use tokio::time::{sleep, Instant}; use tokio::time::{sleep, Instant};
use tracing::error; use tracing::{error, info};
use tracing::info;
pub struct Scheduler { pub struct Scheduler {
config: Config, config: Config,
@ -38,26 +37,28 @@ impl Scheduler {
loop { loop {
if let Err(err) = self.run_update().await.context("Failed to run update") { if let Err(err) = self.run_update().await.context("Failed to run update") {
error!("{:?}", err) error!("{:#}", err);
} }
let sleep_duration =
if let Some(next_run) = self.schedule.upcoming(chrono::Utc).take(1).next() { if let Some(next_run) = self.schedule.upcoming(chrono::Utc).take(1).next() {
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let duration_until_next = next_run.signed_duration_since(now); let duration_until_next = next_run.signed_duration_since(now);
let millis = duration_until_next.num_milliseconds().max(0) as u64;
let duration = Duration::from_millis(millis).max(Duration::from_secs(1));
if duration_until_next.num_seconds() > 0 {
info!( info!(
"Next update scheduled for: {}", "Next update scheduled for: {}",
next_run.format("%Y-%m-%d %H:%M:%S UTC") next_run.format("%Y-%m-%d %H:%M:%S UTC")
); );
duration
} else {
Duration::from_secs(60)
};
let sleep_duration =
Duration::from_secs(duration_until_next.num_seconds() as u64);
sleep(sleep_duration).await; sleep(sleep_duration).await;
} }
} }
}
}
pub async fn run_once(&self) -> Result<()> { pub async fn run_once(&self) -> Result<()> {
info!("Running one-time update"); info!("Running one-time update");
@ -66,48 +67,55 @@ impl Scheduler {
async fn run_update(&self) -> Result<()> { async fn run_update(&self) -> Result<()> {
let start_time = Instant::now(); let start_time = Instant::now();
info!("Starting Docker Compose update cycle"); info!("Starting update cycle");
match self.updater.update_all_compose_files().await { match self.updater.update_all_compose_files().await {
Ok(updated_files) => { Ok(report) => {
let duration = start_time.elapsed(); let duration = start_time.elapsed();
if updated_files.is_empty() {
if report.files_found == 0 {
if let Some(health) = &self.health_handle {
health.report_update_failure();
}
return Err(anyhow!(
"No compose files found under configured compose_paths: {:?}",
self.config.compose_paths
));
}
if report.updated_files.is_empty() {
info!( info!(
"Update cycle completed in {:?} - no files updated", "Update cycle completed in {:?} - scanned {} files, none updated",
duration duration, report.files_found
); );
} else { } else {
info!( let verb = if self.config.dry_run {
"Update cycle completed in {:?} - {} {} files:",
duration,
if self.config.dry_run {
"would update" "would update"
} else { } else {
"updated" "updated"
}, };
updated_files.len() info!(
"Update cycle completed in {:?} - scanned {} files, {} {} files:",
duration,
report.files_found,
verb,
report.updated_files.len()
); );
for file in &updated_files { for file in &report.updated_files {
info!(" - {}", file); info!(" - {}", file);
} }
} }
// Report success to health monitor if let Some(health) = &self.health_handle {
if let Some(ref health) = self.health_handle {
health.report_update_success(); health.report_update_success();
} }
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
let error = e.context("Failed to update Docker Compose files"); if let Some(health) = &self.health_handle {
// Report failure to health monitor
if let Some(ref health) = self.health_handle {
health.report_update_failure(); health.report_update_failure();
} }
Err(e.context("Failed to update Docker Compose files"))
Err(error)
} }
} }
} }
@ -120,18 +128,20 @@ mod tests {
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
fn test_config(schedule: &str) -> Config {
Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: schedule.to_string(),
registries: HashMap::new(),
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec![],
dry_run: true,
}
}
#[test] #[test]
fn test_scheduler_creation() { fn test_scheduler_creation() {
let config = Config { let scheduler = Scheduler::new(test_config("0 0 2 * * *"), None).unwrap();
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 assert!(scheduler
.schedule .schedule
.upcoming(chrono::Utc) .upcoming(chrono::Utc)
@ -141,19 +151,8 @@ mod tests {
} }
#[test] #[test]
fn test_cron_parsing() { fn test_scheduler_with_different_cron() {
let config = Config { let scheduler = Scheduler::new(test_config("0 30 1 * * *"), None).unwrap();
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 assert!(scheduler
.schedule .schedule
.upcoming(chrono::Utc) .upcoming(chrono::Utc)
@ -161,4 +160,17 @@ mod tests {
.next() .next()
.is_some()); .is_some());
} }
#[tokio::test]
async fn test_run_once_errors_when_no_compose_files_found() {
let mut config = test_config("0 0 2 * * *");
config.compose_paths = vec![];
let scheduler = Scheduler::new(config, None).unwrap();
let result = scheduler.run_once().await;
assert!(
result.is_err(),
"an update cycle that finds no compose files should report an error"
);
}
} }

View file

@ -1,6 +1,6 @@
use crate::config::UpdateStrategy; use crate::config::UpdateStrategy;
use crate::version::VersionInfo; use crate::version::VersionInfo;
use tracing::{info, warn}; use tracing::{debug, warn};
pub fn create_selector(strategy: &UpdateStrategy) -> Box<dyn VersionSelector> { pub fn create_selector(strategy: &UpdateStrategy) -> Box<dyn VersionSelector> {
match strategy { match strategy {
@ -27,17 +27,15 @@ impl VersionSelector for LatestVersionSelector {
current_prefix: Option<String>, current_prefix: Option<String>,
current_suffix: Option<String>, current_suffix: Option<String>,
) -> Option<VersionInfo> { ) -> Option<VersionInfo> {
info!("Using update strategy: LatestVersionSelector");
let versions = let versions =
get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix); get_filtered_and_sorted_matching_versions(available, current_prefix, current_suffix);
let latest = versions.first().cloned(); let latest = versions.first().cloned();
if let Some(ref selected) = latest { if let Some(ref selected) = latest {
info!("Selected {} as latest version", selected.version); debug!("Latest version selected: {}", selected.version);
} else { } else {
warn!("No versions available for selection"); warn!("No matching versions available");
} }
latest latest
@ -53,25 +51,19 @@ impl VersionSelector for SmartPreviousMinorSelector {
current_prefix: Option<String>, current_prefix: Option<String>,
current_suffix: Option<String>, current_suffix: Option<String>,
) -> Option<VersionInfo> { ) -> Option<VersionInfo> {
info!("Using update strategy: SmartPreviousMinorSelector");
let versions = let versions =
get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix); get_filtered_and_sorted_matching_versions(available, current_prefix, current_suffix);
let latest = &versions.first()?.version; let latest = &versions.first()?.version;
info!("Latest version available: {}", latest); debug!("Latest version available: {}", latest);
let (target_major, max_minor) = if latest.minor == 0 { 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 { if latest.major == 0 {
info!("Cannot go to previous version of 0.0.x, skipping update"); debug!("Cannot go to previous version of 0.0.x");
return None; 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) (latest.major - 1, None)
} else { } 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)) (latest.major, Some(latest.minor - 1))
}; };
@ -83,46 +75,35 @@ impl VersionSelector for SmartPreviousMinorSelector {
.cloned(); .cloned();
if let Some(ref selected) = selected { if let Some(ref selected) = selected {
info!("Selected {} as latest version", selected.version); debug!("Selected version: {}", selected.version);
} else { } else {
warn!("No versions available for selection"); warn!("No matching versions available for previous minor strategy");
} }
selected selected
} }
} }
fn get_filtered_and_soreted_matching_versions( fn get_filtered_and_sorted_matching_versions(
available: &[VersionInfo], available: &[VersionInfo],
current_prefix: Option<String>, current_prefix: Option<String>,
current_suffix: Option<String>, current_suffix: Option<String>,
) -> Vec<VersionInfo> { ) -> Vec<VersionInfo> {
let mut sorted_versions = available.to_vec(); let mut versions: Vec<VersionInfo> = available
sorted_versions.sort_by(|a, b| b.version.cmp(&a.version));
info!(
"Available versions for selection: {}",
sorted_versions
.iter() .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) .filter(|v| v.prefix == current_prefix && v.suffix == current_suffix)
.cloned()
.collect(); .collect();
versions.sort_by(|a, b| b.version.cmp(&a.version));
info!( debug!(
"Filtered versions with matching prefix and suffix: {}", "Filtered versions (prefix={:?}, suffix={:?}): {}",
filtered_versions current_prefix,
.iter() current_suffix,
.map(|v| v.to_string()) versions.len()
.collect::<Vec<_>>()
.join(", ")
); );
filtered_versions versions
} }
#[cfg(test)] #[cfg(test)]

View file

@ -1,6 +1,11 @@
use regex::Regex; use regex::Regex;
use semver::Version; use semver::Version;
use std::fmt; use std::fmt;
use std::sync::LazyLock;
static VERSION_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?P<prefix>.*?)(?P<version>(?:\d+\.)+\d+)(?P<suffix>.*?)$").unwrap()
});
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct VersionInfo { pub struct VersionInfo {
@ -24,9 +29,7 @@ impl VersionInfo {
/// assert_eq!(version_info.suffix, Some("-alpine-slim".to_string())); /// assert_eq!(version_info.suffix, Some("-alpine-slim".to_string()));
/// ``` /// ```
pub fn from_tag(tag: &str) -> Option<Self> { 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) = VERSION_REGEX.captures(tag) {
if let Some(captures) = re.captures(tag) {
let prefix_part = captures.name("prefix").map_or("", |m| m.as_str()); 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 version_part = captures.name("version").map_or("", |m| m.as_str());
let suffix_part = captures.name("suffix").map_or("", |m| m.as_str()); let suffix_part = captures.name("suffix").map_or("", |m| m.as_str());

View file

@ -3,11 +3,11 @@ use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy};
use docker_compose_updater::registry::{Client, ImageRef}; use docker_compose_updater::registry::{Client, ImageRef};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[tokio::test] #[tokio::test]
async fn test_end_to_end_compose_file_parsing_and_updating() { async fn test_compose_file_parsing() {
let compose_content = r#" let compose_content = r#"
version: '3.8' version: '3.8'
services: services:
@ -30,9 +30,7 @@ services:
let mut temp_file = NamedTempFile::new().unwrap(); let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap(); temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = create_test_config(); let updater = ComposeUpdater::new(create_test_config());
let updater = ComposeUpdater::new(config);
let result = updater let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap()) .parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap(); .unwrap();
@ -54,7 +52,7 @@ services:
} }
#[tokio::test] #[tokio::test]
async fn test_comment_and_formatting_preservation_during_updates() { async fn test_comment_and_formatting_preservation() {
let compose_content = r#" let compose_content = r#"
version: '3.8' version: '3.8'
services: services:
@ -73,9 +71,7 @@ services:
let mut temp_file = NamedTempFile::new().unwrap(); let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(compose_content.as_bytes()).unwrap(); temp_file.write_all(compose_content.as_bytes()).unwrap();
let config = create_test_config(); let updater = ComposeUpdater::new(create_test_config());
let updater = ComposeUpdater::new(config);
let result = updater let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap()) .parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap(); .unwrap();
@ -87,34 +83,34 @@ services:
.original_line .original_line
.contains("# Database comment")); .contains("# Database comment"));
// Replace unquoted image -> stays unquoted, preserves comment
let new_content = updater let new_content = updater
.replace_image_in_content(&result.content, &result.services[0], "nginx:1.20.0") .replace_image_in_content(&result.content, &result.services[0], "nginx:1.20.0")
.unwrap(); .unwrap();
assert!(new_content.contains("# This is a comment")); assert!(new_content.contains("nginx:1.20.0 # This is a comment"));
assert!(new_content.contains("nginx:1.20.0"));
assert!(!new_content.contains("nginx:1.21.0")); assert!(!new_content.contains("nginx:1.21.0"));
// Replace quoted image -> stays quoted, preserves comment
let new_content = updater let new_content = updater
.replace_image_in_content(&new_content, &result.services[1], "postgres:14.0") .replace_image_in_content(&new_content, &result.services[1], "postgres:14.0")
.unwrap(); .unwrap();
assert!(new_content.contains("# Database comment")); assert!(new_content.contains("\"postgres:14.0\" # Database comment"));
assert!(new_content.contains("\"postgres:14.0\""));
assert!(!new_content.contains("postgres:13.7")); assert!(!new_content.contains("postgres:13.7"));
} }
#[tokio::test] #[tokio::test]
async fn test_empty_compose_paths_handling() { async fn test_empty_compose_paths() {
let config = Config { let config = Config {
compose_paths: vec![], compose_paths: vec![],
..create_test_config() ..create_test_config()
}; };
let updater = ComposeUpdater::new(config); let updater = ComposeUpdater::new(config);
let result = updater.update_all_compose_files().await; let result = updater.update_all_compose_files().await.unwrap();
assert!(result.is_ok()); assert_eq!(result.files_found, 0);
assert!(result.unwrap().is_empty()); assert!(result.updated_files.is_empty());
} }
#[test] #[test]
@ -140,6 +136,77 @@ fn test_config_defaults_and_image_filtering() {
assert!(!config_with_ignores.is_image_ignored("nginx:1.21.0")); assert!(!config_with_ignores.is_image_ignored("nginx:1.21.0"));
} }
#[test]
fn test_env_var_substitution() {
std::env::set_var("TEST_TOKEN", "test_value_123");
let test_yaml = r#"
registries:
"test.registry":
url: "https://test.registry"
auth_token: "${TEST_TOKEN}"
"#;
let expanded = Config::expand_env_vars(test_yaml);
assert!(expanded.contains("test_value_123"));
assert!(!expanded.contains("${TEST_TOKEN}"));
std::env::remove_var("TEST_TOKEN");
}
#[test]
fn test_dollar_var_and_unset_var_expansion() {
std::env::set_var("TEST_DOLLAR_TOKEN", "dollar_value_456");
let expanded = Config::expand_env_vars("token: $TEST_DOLLAR_TOKEN");
assert!(expanded.contains("dollar_value_456"));
std::env::remove_var("TEST_DOLLAR_TOKEN");
let expanded = Config::expand_env_vars("token: ${DEFINITELY_UNSET_VAR}");
assert!(!expanded.contains("DEFINITELY_UNSET_VAR"));
assert!(expanded.contains("token: "));
}
#[tokio::test]
#[ignore = "Only run in CI with GITHUB_TOKEN set"]
async fn test_ghcr_authentication_e2e() {
let github_token = std::env::var("GITHUB_TOKEN").expect("GITHUB_TOKEN must be set");
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: Some(github_token),
},
);
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec!["localhost".to_string()],
dry_run: true,
};
let client = Client::new(config);
let image_ref = ImageRef::parse("ghcr.io/schmelczer/vault-link:0.5.1").unwrap();
let versions = client.get_available_versions(&image_ref).await.unwrap();
assert!(!versions.is_empty());
assert!(
versions.iter().any(|v| v.version.to_string() == "0.5.1"),
"Should find version 0.5.1"
);
}
fn create_test_config() -> Config { fn create_test_config() -> Config {
let mut registries = HashMap::new(); let mut registries = HashMap::new();
registries.insert( registries.insert(
@ -159,101 +226,3 @@ fn create_test_config() -> Config {
dry_run: true, dry_run: true,
} }
} }
#[test]
fn test_env_var_substitution() {
// Test environment variable substitution in config
std::env::set_var("TEST_TOKEN", "test_value_123");
let test_yaml = r#"
registries:
"test.registry":
url: "https://test.registry"
auth_token: "${TEST_TOKEN}"
"#;
let expanded = Config::expand_env_vars(test_yaml);
assert!(expanded.contains("test_value_123"));
assert!(!expanded.contains("${TEST_TOKEN}"));
// Clean up
std::env::remove_var("TEST_TOKEN");
}
#[test]
fn test_config_loading_with_env_vars() {
// Set up test environment variable
std::env::set_var("GITHUB_TOKEN", "ghp_test_token_for_testing");
// Test that config loading works with our test config
if std::path::Path::new("config.test.yaml").exists() {
let config = Config::load(Path::new("config.test.yaml").to_path_buf()).unwrap();
if let Some(ghcr_config) = config.registries.get("ghcr.io") {
// Should have resolved the environment variable
assert_eq!(
ghcr_config.auth_token,
Some("ghp_test_token_for_testing".to_string())
);
}
}
// Clean up
std::env::remove_var("GITHUB_TOKEN");
}
#[tokio::test]
#[ignore = "Only run in CI with GITHUB_TOKEN set"]
async fn test_ghcr_authentication_e2e() {
let github_token = std::env::var("GITHUB_TOKEN").unwrap();
// This test validates that Docker Registry v2 authentication works with GHCR
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(),
// Use the token we validated above
auth_token: Some(github_token),
},
);
let config = Config {
compose_paths: vec![PathBuf::from("./test")],
schedule: "0 0 2 * * *".to_string(),
registries,
update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor,
ignore_images: vec!["localhost".to_string()],
dry_run: true,
};
let client = Client::new(config);
// Test with the actual image that was failing
let image_ref = ImageRef::parse("ghcr.io/schmelczer/vault-link:0.5.1").unwrap();
let result = client.get_available_versions(&image_ref).await;
match result {
Ok(versions) => {
assert!(!versions.is_empty(), "Should return at least one version");
// Verify that we can find the specific version 0.5.1
let has_target_version = versions.iter().any(|v| v.version.to_string() == "0.5.1");
assert!(
has_target_version,
"Should find target version 0.5.1 in available versions"
);
}
Err(e) => {
panic!("GHCR authentication test failed: {e}");
}
}
}

View file

@ -8,7 +8,6 @@ use tempfile::NamedTempFile;
fn test_config_default_values() { fn test_config_default_values() {
let config = Config::default(); let config = Config::default();
// Verify all default values are reasonable
assert_eq!(config.schedule, "0 0 2 * * *"); assert_eq!(config.schedule, "0 0 2 * * *");
assert_eq!( assert_eq!(
config.update_strategy, config.update_strategy,
@ -16,10 +15,8 @@ fn test_config_default_values() {
); );
assert!(!config.dry_run); assert!(!config.dry_run);
assert!(config.ignore_images.is_empty()); assert!(config.ignore_images.is_empty());
// compose_paths should be a valid Vec (length check is always true for usize) assert!(!config.compose_paths.is_empty());
let _paths_count = config.compose_paths.len();
// Should have default registries configured
assert!(config.registries.contains_key("docker.io")); assert!(config.registries.contains_key("docker.io"));
assert!(config.registries.contains_key("ghcr.io")); assert!(config.registries.contains_key("ghcr.io"));
@ -29,12 +26,10 @@ fn test_config_default_values() {
let ghcr_registry = &config.registries["ghcr.io"]; let ghcr_registry = &config.registries["ghcr.io"];
assert_eq!(ghcr_registry.url, "https://ghcr.io"); assert_eq!(ghcr_registry.url, "https://ghcr.io");
assert!(ghcr_registry.auth_token.is_none());
} }
#[test] #[test]
fn test_config_from_different_file_formats() { fn test_config_from_yaml_file() {
// Test loading from a properly formatted YAML file
let valid_yaml = r#" let valid_yaml = r#"
compose_paths: compose_paths:
- "./docker-compose.yml" - "./docker-compose.yml"
@ -57,10 +52,7 @@ registries:
temp_file.write_all(valid_yaml.as_bytes()).unwrap(); temp_file.write_all(valid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap(); temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf()); let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert!(result.is_ok(), "Should load valid YAML config");
let config = result.unwrap();
assert_eq!(config.compose_paths.len(), 2); assert_eq!(config.compose_paths.len(), 2);
assert_eq!(config.schedule, "0 0 3 * * *"); assert_eq!(config.schedule, "0 0 3 * * *");
assert_eq!(config.update_strategy, UpdateStrategy::Latest); assert_eq!(config.update_strategy, UpdateStrategy::Latest);
@ -74,45 +66,18 @@ registries:
} }
#[test] #[test]
fn test_config_with_invalid_yaml_syntax() { fn test_config_rejects_invalid_yaml() {
let invalid_yamls = [ let invalid_yaml = r#"{ invalid yaml syntax ["#;
// Invalid YAML syntax
r#"
compose_paths:
- "./docker-compose.yml"
invalid_indent:
schedule: "0 0 2 * * *"
"#,
// Invalid field types
r#"
compose_paths: "not an array"
schedule: 123
dry_run: "not a boolean"
"#,
// Completely invalid YAML
r#"
{ invalid yaml syntax [
"#,
];
for (i, invalid_yaml) in invalid_yamls.iter().enumerate() {
let mut temp_file = NamedTempFile::new().unwrap(); let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(invalid_yaml.as_bytes()).unwrap(); temp_file.write_all(invalid_yaml.as_bytes()).unwrap();
temp_file.flush().unwrap(); temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf()); let result = Config::load(temp_file.path().to_path_buf());
// Should either fail gracefully or fall back to defaults
if result.is_ok() {
let config = result.unwrap();
// If it succeeds, should have reasonable defaults
assert!( assert!(
!config.schedule.is_empty(), result.is_err(),
"Schedule should not be empty for test case {i}" "Completely invalid YAML should fail to load"
); );
}
// If it fails, that's also acceptable behavior for invalid YAML
}
} }
#[test] #[test]
@ -123,10 +88,6 @@ fn test_config_update_strategy_parsing() {
"LatestPatchOfPreviousMinor", "LatestPatchOfPreviousMinor",
UpdateStrategy::LatestPatchOfPreviousMinor, UpdateStrategy::LatestPatchOfPreviousMinor,
), ),
(
"LatestPatchOfPreviousMinor",
UpdateStrategy::LatestPatchOfPreviousMinor,
),
]; ];
for (strategy_str, expected_strategy) in strategies { for (strategy_str, expected_strategy) in strategies {
@ -142,19 +103,13 @@ update_strategy: "{strategy_str}"
temp_file.write_all(yaml_config.as_bytes()).unwrap(); temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap(); temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf()); let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert!(
result.is_ok(),
"Should parse valid strategy: {strategy_str}"
);
let config = result.unwrap();
assert_eq!(config.update_strategy, expected_strategy); assert_eq!(config.update_strategy, expected_strategy);
} }
} }
#[test] #[test]
fn test_config_with_invalid_update_strategy() { fn test_config_rejects_invalid_update_strategy() {
let yaml_config = r#" let yaml_config = r#"
compose_paths: [] compose_paths: []
schedule: "0 0 2 * * *" schedule: "0 0 2 * * *"
@ -166,15 +121,12 @@ update_strategy: "InvalidStrategy"
temp_file.flush().unwrap(); temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf()); let result = Config::load(temp_file.path().to_path_buf());
assert!(result.is_err());
assert!(result.is_err(), "Should not parse invalid update strategy");
} }
#[test] #[test]
fn test_image_ignore_patterns_functionality() { fn test_image_ignore_patterns() {
// Test various ignore patterns
let test_cases = vec![ let test_cases = vec![
// (ignore_pattern, image_to_test, should_be_ignored)
("localhost", "localhost:5000/app:latest", true), ("localhost", "localhost:5000/app:latest", true),
("localhost", "nginx:latest", false), ("localhost", "nginx:latest", false),
("127.0.0.1", "127.0.0.1:5000/app:latest", true), ("127.0.0.1", "127.0.0.1:5000/app:latest", true),
@ -182,6 +134,7 @@ fn test_image_ignore_patterns_functionality() {
("ghcr.io", "ghcr.io/user/app:latest", true), ("ghcr.io", "ghcr.io/user/app:latest", true),
("ghcr.io", "docker.io/nginx:latest", false), ("ghcr.io", "docker.io/nginx:latest", false),
("test-", "test-app:latest", true), ("test-", "test-app:latest", true),
// "app-test:latest" does NOT contain "test-" (it has "test:" after the dash)
("test-", "app-test:latest", false), ("test-", "app-test:latest", false),
]; ];
@ -191,10 +144,13 @@ fn test_image_ignore_patterns_functionality() {
..Config::default() ..Config::default()
}; };
let result = config.is_image_ignored(image_to_test);
assert_eq!( assert_eq!(
result, should_be_ignored, config.is_image_ignored(image_to_test),
"Pattern '{ignore_pattern}' with image '{image_to_test}' should be ignored: {should_be_ignored}" should_be_ignored,
"Pattern '{}' with image '{}': expected ignored={}",
ignore_pattern,
image_to_test,
should_be_ignored
); );
} }
} }
@ -211,31 +167,17 @@ fn test_multiple_ignore_patterns() {
..Config::default() ..Config::default()
}; };
let test_cases = vec![ assert!(config.is_image_ignored("localhost:5000/app:latest"));
("localhost:5000/app:latest", true), assert!(config.is_image_ignored("127.0.0.1:5000/app:latest"));
("127.0.0.1:5000/app:latest", true), assert!(config.is_image_ignored("test-app:latest"));
("test-app:latest", true), assert!(config.is_image_ignored("ghcr.io/user/app:latest"));
("ghcr.io/user/app:latest", true), assert!(!config.is_image_ignored("docker.io/nginx:latest"));
("docker.io/nginx:latest", false), assert!(!config.is_image_ignored("registry.example.com/app:latest"));
("registry.example.com/app:latest", false),
("app-test:latest", false),
];
for (image, should_be_ignored) in test_cases {
let result = config.is_image_ignored(image);
assert_eq!(
result, should_be_ignored,
"Image '{image}' should be ignored: {should_be_ignored}"
);
}
} }
#[test] #[test]
fn test_registry_configuration_validation() { fn test_registry_configuration() {
// Test registry configs with various configurations
let mut registries = HashMap::new(); let mut registries = HashMap::new();
// Registry with just URL
registries.insert( registries.insert(
"simple.registry.com".to_string(), "simple.registry.com".to_string(),
RegistryConfig { RegistryConfig {
@ -243,8 +185,6 @@ fn test_registry_configuration_validation() {
auth_token: None, auth_token: None,
}, },
); );
// Registry with auth token
registries.insert( registries.insert(
"auth.registry.com".to_string(), "auth.registry.com".to_string(),
RegistryConfig { RegistryConfig {
@ -258,17 +198,13 @@ fn test_registry_configuration_validation() {
..Config::default() ..Config::default()
}; };
// Verify registries are properly configured assert!(config.registries["simple.registry.com"]
assert!(config.registries.contains_key("simple.registry.com")); .auth_token
assert!(config.registries.contains_key("auth.registry.com")); .is_none());
assert_eq!(
let simple_registry = &config.registries["simple.registry.com"]; config.registries["auth.registry.com"].auth_token,
assert_eq!(simple_registry.url, "https://simple.registry.com"); Some("secret-token".to_string())
assert!(simple_registry.auth_token.is_none()); );
let auth_registry = &config.registries["auth.registry.com"];
assert_eq!(auth_registry.url, "https://auth.registry.com");
assert_eq!(auth_registry.auth_token, Some("secret-token".to_string()));
} }
#[test] #[test]
@ -284,36 +220,19 @@ registries: {}
temp_file.write_all(yaml_config.as_bytes()).unwrap(); temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap(); temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf()); let config = Config::load(temp_file.path().to_path_buf()).unwrap();
if result.is_ok() {
let config = result.unwrap();
assert!(config.compose_paths.is_empty()); assert!(config.compose_paths.is_empty());
assert!(config.ignore_images.is_empty()); assert!(config.ignore_images.is_empty());
// Empty schedule should either be rejected or have a default assert!(config.schedule.is_empty());
assert!(!config.schedule.is_empty() || config.schedule.is_empty()); // Either is acceptable assert!(config.registries.is_empty());
}
// If loading fails for empty schedule, that's also acceptable
} }
#[test] #[test]
fn test_config_compose_paths_handling() { fn test_config_compose_paths_handling() {
// Test various compose path configurations
let test_cases = vec![ let test_cases = vec![
// Single file path
vec!["./docker-compose.yml"], vec!["./docker-compose.yml"],
// Multiple file paths
vec!["./docker-compose.yml", "./docker-compose.override.yml"], vec!["./docker-compose.yml", "./docker-compose.override.yml"],
// Directory paths
vec!["./services/", "./environments/"], vec!["./services/", "./environments/"],
// Mixed paths
vec!["./docker-compose.yml", "./services/", "./override.yml"],
// Relative and absolute-looking paths
vec![
"docker-compose.yml",
"/app/config/compose.yml",
"../compose.yml",
],
]; ];
for paths in test_cases { for paths in test_cases {
@ -334,13 +253,33 @@ schedule: "0 0 2 * * *"
temp_file.write_all(yaml_config.as_bytes()).unwrap(); temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap(); temp_file.flush().unwrap();
let result = Config::load(temp_file.path().to_path_buf()); let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert!(result.is_ok(), "Should handle paths: {paths:?}");
let config = result.unwrap();
assert_eq!(config.compose_paths.len(), paths.len()); assert_eq!(config.compose_paths.len(), paths.len());
for (i, expected_path) in paths.iter().enumerate() { for (i, expected_path) in paths.iter().enumerate() {
assert_eq!(config.compose_paths[i], PathBuf::from(expected_path)); assert_eq!(config.compose_paths[i], PathBuf::from(expected_path));
} }
} }
} }
#[test]
fn test_load_normalizes_empty_auth_token_to_none() {
// An auth_token referencing an unset variable expands to "" and should be
// treated as "no token" rather than an empty credential.
let yaml_config = r#"
compose_paths: []
schedule: "0 0 2 * * *"
registries:
private.registry.com:
url: "https://private.registry.com"
auth_token: "${DCU_DEFINITELY_UNSET_TOKEN}"
"#;
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(yaml_config.as_bytes()).unwrap();
temp_file.flush().unwrap();
let config = Config::load(temp_file.path().to_path_buf()).unwrap();
assert!(config.registries["private.registry.com"]
.auth_token
.is_none());
}

View file

@ -9,153 +9,125 @@ use tempfile::{NamedTempFile, TempDir};
#[test] #[test]
fn test_scheduler_invalid_cron_expressions() { fn test_scheduler_invalid_cron_expressions() {
// Test that scheduler now properly rejects invalid cron expressions instead of falling back let invalid_crons = [
let invalid_cron_configs = [
"invalid cron", "invalid cron",
"0 0 25 * * *", // Invalid hour "0 0 25 * * *",
"60 0 2 * * *", // Invalid minute "60 0 2 * * *",
"", // Empty string "",
"0 0 2 * *", // Missing field "0 0 2 * *",
]; ];
for invalid_cron in invalid_cron_configs { for cron in invalid_crons {
let config = Config { let config = Config {
compose_paths: vec![PathBuf::from("./test")], compose_paths: vec![PathBuf::from("./test")],
schedule: invalid_cron.to_string(), schedule: cron.to_string(),
registries: HashMap::new(), registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest, update_strategy: UpdateStrategy::Latest,
ignore_images: vec![], ignore_images: vec![],
dry_run: true, dry_run: true,
}; };
let result = Scheduler::new(config, None);
assert!( assert!(
result.is_err(), Scheduler::new(config, None).is_err(),
"Expected error for invalid cron expression: '{invalid_cron}'" "Expected error for invalid cron expression: '{cron}'"
); );
} }
} }
#[test] #[test]
fn test_scheduler_valid_cron_expressions() { fn test_scheduler_valid_cron_expressions() {
// Test that scheduler accepts valid cron expressions let valid_crons = [
let valid_cron_configs = [ "0 0 2 * * *",
"0 0 2 * * *", // Default - 2 AM daily "0 30 1 * * *",
"0 30 1 * * *", // 1:30 AM daily "0 0 */6 * * *",
"0 0 */6 * * *", // Every 6 hours "0 0 2 * * MON",
"0 0 2 * * MON", // Mondays at 2 AM "0 15 14 1 * *",
"0 15 14 1 * *", // 1st of month at 2:15 PM
]; ];
for valid_cron in valid_cron_configs { for cron in valid_crons {
let config = Config { let config = Config {
compose_paths: vec![PathBuf::from("./test")], compose_paths: vec![PathBuf::from("./test")],
schedule: valid_cron.to_string(), schedule: cron.to_string(),
registries: HashMap::new(), registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest, update_strategy: UpdateStrategy::Latest,
ignore_images: vec![], ignore_images: vec![],
dry_run: true, dry_run: true,
}; };
let result = Scheduler::new(config, None);
assert!( assert!(
result.is_ok(), Scheduler::new(config, None).is_ok(),
"Expected success for valid cron expression: '{valid_cron}'" "Expected success for valid cron expression: '{cron}'"
); );
} }
} }
#[test] #[test]
fn test_compose_invalid_file_paths() { fn test_compose_invalid_file_paths() {
let config = create_test_config(); let updater = ComposeUpdater::new(create_test_config());
let updater = ComposeUpdater::new(config);
// Test with completely invalid path assert!(updater
let result = updater.parse_compose_file("/nonexistent/path/file.yml"); .parse_compose_file("/nonexistent/path/file.yml")
assert!(result.is_err(), "Should fail for nonexistent file"); .is_err());
// Test with directory instead of file
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let result = updater.parse_compose_file(temp_dir.path().to_str().unwrap()); assert!(updater
assert!(result.is_err(), "Should fail when path is a directory"); .parse_compose_file(temp_dir.path().to_str().unwrap())
.is_err());
} }
#[test] #[test]
fn test_compose_malformed_yaml() { fn test_compose_malformed_yaml() {
let config = create_test_config(); let updater = ComposeUpdater::new(create_test_config());
let updater = ComposeUpdater::new(config);
let malformed_yamls = [ // Missing services section -> no services found
// Invalid YAML syntax
r#"
version: '3.8'
services:
web:
image: nginx:1.21.0
invalid_indent
ports:
- "80:80"
"#,
// Missing required fields
r#"
version: '3.8'
# Missing services section
"#,
// Completely empty file
"",
// Invalid version format
r#"
version: invalid
services:
web:
image: nginx:1.21.0
"#,
];
for (i, malformed_yaml) in malformed_yamls.iter().enumerate() {
let mut temp_file = NamedTempFile::new().unwrap(); let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(malformed_yaml.as_bytes()).unwrap(); temp_file.write_all(b"version: '3.8'\n").unwrap();
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert!(result.services.is_empty());
let result = updater.parse_compose_file(temp_file.path().to_str().unwrap()); // Empty file -> no services found
let mut temp_file = NamedTempFile::new().unwrap();
temp_file.write_all(b"").unwrap();
let result = updater
.parse_compose_file(temp_file.path().to_str().unwrap())
.unwrap();
assert!(result.services.is_empty());
// We expect these to either fail or return empty services (graceful degradation) // Valid services section with invalid version field -> still finds services
if result.is_ok() { let mut temp_file = NamedTempFile::new().unwrap();
let compose_file = result.unwrap(); temp_file
// If it parses successfully, it should at least not crash .write_all(b"version: invalid\nservices:\n web:\n image: nginx:1.21.0\n")
assert!( .unwrap();
compose_file.services.is_empty() || !compose_file.services.is_empty(), let result = updater
"Test case {i} should either fail or succeed gracefully" .parse_compose_file(temp_file.path().to_str().unwrap())
); .unwrap();
} assert_eq!(result.services.len(), 1);
// If it fails, that's also acceptable behavior for malformed YAML
}
} }
#[test] #[test]
fn test_image_ref_parsing_edge_cases() { fn test_image_ref_parsing_edge_cases() {
// Test that image parsing doesn't crash on edge cases // Empty string parses but produces empty name (no panic)
let long_name = "a".repeat(300); let result = ImageRef::parse("").unwrap();
let edge_case_refs = [ assert_eq!(result.name, "");
"", // Empty string assert_eq!(result.tag, "latest");
":", // Just colon
"image:", // Missing tag
":tag", // Missing image
"registry.com/", // Missing image name
&long_name, // Extremely long name
];
for edge_case_ref in edge_case_refs { // Missing tag defaults to "latest"
let result = ImageRef::parse(edge_case_ref); let result = ImageRef::parse("nginx").unwrap();
// The main goal is that parsing doesn't crash and returns a result assert_eq!(result.tag, "latest");
// Some might succeed (with defaults) or fail - both are acceptable assert_eq!(result.name, "nginx");
if result.is_ok() {
let _image_ref = result.unwrap(); // Registry with namespace
// If it succeeds, that's fine - the parser is robust let result = ImageRef::parse("ghcr.io/user/app:v1").unwrap();
// We don't make strict assertions about the content since assert_eq!(result.registry, "ghcr.io");
// the parser may use defaults for edge cases assert_eq!(result.namespace, Some("user".to_string()));
} assert_eq!(result.name, "app");
// If they fail, that's also perfectly acceptable behavior assert_eq!(result.tag, "v1");
}
// Very long name shouldn't panic
let long_name = "a".repeat(300);
let _ = ImageRef::parse(&long_name);
} }
#[test] #[test]
@ -163,7 +135,7 @@ fn test_registry_unknown_registry() {
let config = Config { let config = Config {
compose_paths: vec![], compose_paths: vec![],
schedule: "0 0 2 * * *".to_string(), schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(), // Empty registries registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest, update_strategy: UpdateStrategy::Latest,
ignore_images: vec![], ignore_images: vec![],
dry_run: true, dry_run: true,
@ -171,7 +143,6 @@ fn test_registry_unknown_registry() {
let registry_client = RegistryClient::new(config); let registry_client = RegistryClient::new(config);
// Test with an unknown registry
let image_ref = ImageRef { let image_ref = ImageRef {
registry: "unknown.registry.com".to_string(), registry: "unknown.registry.com".to_string(),
namespace: Some("user".to_string()), namespace: Some("user".to_string()),
@ -179,57 +150,43 @@ fn test_registry_unknown_registry() {
tag: "1.0.0".to_string(), tag: "1.0.0".to_string(),
}; };
// This should fail because the registry is not configured
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(registry_client.get_available_versions(&image_ref)); let result = rt.block_on(registry_client.get_available_versions(&image_ref));
assert!(result.is_err(), "Should fail for unknown registry"); assert!(result.is_err());
let error_msg = result.unwrap_err().to_string(); let error_msg = result.unwrap_err().to_string();
assert!( assert!(
error_msg.contains("Unknown registry"), error_msg.contains("Unknown registry"),
"Error should mention unknown registry, got: {error_msg}" "Expected 'Unknown registry' in error, got: {error_msg}"
); );
} }
#[tokio::test] #[tokio::test]
async fn test_compose_update_with_empty_paths() { async fn test_compose_update_with_empty_paths() {
let config = Config { let config = Config {
compose_paths: vec![], // Empty paths compose_paths: vec![],
..create_test_config() ..create_test_config()
}; };
let updater = ComposeUpdater::new(config); let updater = ComposeUpdater::new(config);
// Should handle empty paths gracefully let result = updater.update_all_compose_files().await.unwrap();
let result = updater.update_all_compose_files().await; assert_eq!(result.files_found, 0);
assert!(result.is_ok(), "Should handle empty paths gracefully"); assert!(result.updated_files.is_empty());
assert!(
result.unwrap().is_empty(),
"Should return empty list for empty paths"
);
} }
#[test] #[test]
fn test_config_edge_cases() { fn test_config_edge_cases() {
// Test config with minimal valid data
let minimal_config = Config { let minimal_config = Config {
compose_paths: vec![], // Empty paths compose_paths: vec![],
schedule: "0 0 2 * * *".to_string(), schedule: "0 0 2 * * *".to_string(),
registries: HashMap::new(), // No registries registries: HashMap::new(),
update_strategy: UpdateStrategy::Latest, update_strategy: UpdateStrategy::Latest,
ignore_images: vec![], ignore_images: vec![],
dry_run: true, dry_run: true,
}; };
// Should be able to create scheduler with minimal config assert!(Scheduler::new(minimal_config.clone(), None).is_ok());
let scheduler_result = Scheduler::new(minimal_config.clone(), None);
assert!(
scheduler_result.is_ok(),
"Should accept minimal valid config"
);
// Should be able to create compose updater with minimal config
let _updater = ComposeUpdater::new(minimal_config.clone()); let _updater = ComposeUpdater::new(minimal_config.clone());
// This should not panic
assert!(!minimal_config.is_image_ignored("any-image:tag")); assert!(!minimal_config.is_image_ignored("any-image:tag"));
} }
@ -238,8 +195,7 @@ fn test_version_selection_with_mismatched_prefixes_suffixes() {
use docker_compose_updater::strategy::create_selector; use docker_compose_updater::strategy::create_selector;
use docker_compose_updater::version::VersionInfo; use docker_compose_updater::version::VersionInfo;
let config = create_test_config(); let selector = create_selector(&create_test_config().update_strategy);
let selector = create_selector(&config.update_strategy);
let current_prefix = Some("v".to_string()); let current_prefix = Some("v".to_string());
let current_suffix = Some("-alpine".to_string()); let current_suffix = Some("-alpine".to_string());
@ -251,10 +207,9 @@ fn test_version_selection_with_mismatched_prefixes_suffixes() {
]; ];
let target = selector.select_target_version(&available, current_prefix, current_suffix); let target = selector.select_target_version(&available, current_prefix, current_suffix);
assert!( assert!(
target.is_none(), target.is_none(),
"Should not find target when prefix/suffix don't match" "No versions match both prefix 'v' and suffix '-alpine'"
); );
} }