From 07e582e2beb690b41b5426c3302ed98074ddce0c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 8 Aug 2026 21:32:27 +0100 Subject: [PATCH] Add username --- config.example.yaml | 20 +- src/compose/updater.rs | 21 +- src/config.rs | 22 +++ src/registry.rs | 322 +++++++++++++++---------------- src/strategy.rs | 104 ++++++++-- tests/integration_tests.rs | 3 + tests/test_compose_operations.rs | 2 + tests/test_config_validation.rs | 55 +++++- tests/test_error_handling.rs | 1 + 9 files changed, 358 insertions(+), 192 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 06a93c3..a716faa 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,16 +12,26 @@ compose_paths: schedule: "0 0 2 * * *" # Registry configurations -# All registries use the standard Docker Registry v2 API +# All registries use the standard Docker Registry v2 API. Public repositories need +# no credentials: the updater requests an anonymous pull token when challenged. +# Defaults for docker.io and ghcr.io are filled in for any entry left out here. registries: "docker.io": url: "https://registry-1.docker.io" - # Docker Hub uses the standard registry API endpoint + # Docker Hub validates the basic-auth username, so a personal access token + # only works alongside the account that owns it. Both are optional; without + # them public images are still read anonymously. + # username: "your-docker-hub-username" + # auth_token: "${DOCKERHUB_TOKEN}" "ghcr.io": url: "https://ghcr.io" auth_token: "${GITHUB_TOKEN}" # GitHub token must have 'read:packages' scope and access to the target repositories + # `username` is not needed here: the token itself carries the identity + + "lscr.io": + url: "https://lscr.io" "registry.gitlab.com": url: "https://registry.gitlab.com" @@ -37,10 +47,6 @@ registries: update_strategy: "LatestPatchOfPreviousMinor" # Images to ignore (substring matching) -ignore_images: - - "localhost" - - "127.0.0.1" - - "local/" +ignore_images: [] -# Dry run mode - if true, no files will be modified dry_run: false diff --git a/src/compose/updater.rs b/src/compose/updater.rs index 05d0fe2..e5e5bbd 100644 --- a/src/compose/updater.rs +++ b/src/compose/updater.rs @@ -296,10 +296,27 @@ fn choose_new_tag( strategy: &UpdateStrategy, ) -> Option { let selector = create_selector(strategy); - let target = - selector.select_target_version(available_versions, current_prefix, current_suffix)?; + // Warn here rather than inside the selector: this is the innermost scope that + // knows *which* image was skipped, and a bare "no matching versions" line is + // undiagnosable in a run covering dozens of services. + let Some(target) = selector.select_target_version( + available_versions, + current_prefix.clone(), + current_suffix.clone(), + ) else { + warn!( + "No {:?} candidate for {} (prefix {:?}, suffix {:?}) among {} parseable registry tags", + strategy, + image_ref, + current_prefix, + current_suffix, + available_versions.len() + ); + return None; + }; if target.version <= *current_version { + debug!("{} is already at or above the target {}", image_ref, target); return None; } diff --git a/src/config.rs b/src/config.rs index 5aced8d..404f969 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,8 +24,17 @@ pub struct Config { pub struct RegistryConfig { pub url: String, pub auth_token: Option, + /// Basic-auth username paired with `auth_token` when exchanging a registry + /// challenge for a bearer token. ghcr.io and GitLab ignore it (the token + /// carries the identity), hence the "token" default; Docker Hub validates it + /// and rejects anything but the real account name, so a `dckr_pat_…` token + /// needs the owning username set here. + pub username: Option, } +/// Username used with `auth_token` when a registry config does not set one. +pub const DEFAULT_AUTH_USERNAME: &str = "token"; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub enum UpdateStrategy { #[default] @@ -41,6 +50,7 @@ impl Default for Config { RegistryConfig { url: "https://registry-1.docker.io".to_string(), auth_token: None, + username: None, }, ); registries.insert( @@ -48,6 +58,7 @@ impl Default for Config { RegistryConfig { url: "https://ghcr.io".to_string(), auth_token: std::env::var("GITHUB_TOKEN").ok(), + username: None, }, ); @@ -69,9 +80,20 @@ impl Config { let expanded_content = Self::expand_env_vars(&content); let mut config: Self = serde_yaml::from_str(&expanded_content)?; config.normalize_auth_tokens(); + config.restore_missing_default_registries(); Ok(config) } + /// A `registries` block in the config file replaces the defaults wholesale + /// rather than merging, so one that lists only (say) ghcr.io would leave + /// Docker Hub unresolvable. Put the defaults back for any key the file did + /// not set; an explicit entry always wins. + fn restore_missing_default_registries(&mut self) { + for (name, registry) in Self::default().registries { + self.registries.entry(name).or_insert(registry); + } + } + pub fn expand_env_vars(content: &str) -> String { ENV_VAR_REGEX .replace_all(content, |caps: ®ex::Captures| { diff --git a/src/registry.rs b/src/registry.rs index 8c8eec6..6abc049 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,25 +1,24 @@ -use crate::config::{Config, RegistryConfig}; +use crate::config::{Config, RegistryConfig, DEFAULT_AUTH_USERNAME}; use crate::version::VersionInfo; use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use reqwest::{header::HeaderMap, Client as HttpClient, Response, StatusCode}; -use serde::Deserialize; -use std::sync::LazyLock; +use std::collections::HashSet; +use std::sync::{LazyLock, Mutex}; use std::time::Duration; use tokio::time::sleep; use tracing::{debug, warn}; -// Docker Hub silently clamps page_size to 100, so request exactly that. -const DOCKERHUB_PAGE_SIZE: usize = 100; -// ghcr.io / lscr.io (and distribution-spec registries) cap `n` at 1000; a larger -// value is clamped, so 1000 minimises the number of requests (and thus 429s). +// Registries implementing the distribution spec (Docker Hub, ghcr.io, lscr.io) +// cap `n` at 1000; a larger value is clamped, so 1000 minimises the number of +// requests (and thus 429s). const OCI_PAGE_SIZE: usize = 1000; -// Upper bound on tags fetched per image, as a runaway guard only. Tag listings on -// Docker Hub and ghcr.io/lscr.io are NOT ordered by version (Docker Hub's order is -// undefined; ghcr.io/lscr.io are chronological), so we must scan the whole list to -// reliably find the highest version rather than truncate and risk missing it. Real -// repos (e.g. jellyfin/jellyfin at ~13.6k tags) finish far below this; hitting it -// means the result may be incomplete and is logged loudly. +// Upper bound on tags fetched per image, as a runaway guard only. Tag listings are +// NOT ordered by version (Docker Hub's registry API is lexical, ghcr.io/lscr.io are +// chronological), so we must scan the whole list to reliably find the highest +// version rather than truncate and risk missing it. Real repos (e.g. +// jellyfin/jellyfin at ~13.6k tags) finish far below this; hitting it means the +// result may be incomplete and is logged loudly. const MAX_TAGS_SCANNED: usize = 50_000; const MAX_RETRY_ATTEMPTS: u32 = 5; const MAX_AUTH_ATTEMPTS: u32 = 2; @@ -101,17 +100,6 @@ impl std::fmt::Display for ImageRef { } } -#[derive(Deserialize)] -struct DockerHubTagsResponse { - results: Vec, - next: Option, -} - -#[derive(Deserialize)] -struct DockerHubTag { - name: String, -} - /// A fully-read HTTP response. The body is read inside the retry scope so that a /// connection dropped mid-body is retried like any other transport failure; /// callers therefore receive the body as an owned string rather than a streaming @@ -125,6 +113,14 @@ struct FetchResult { pub struct Client { http_client: HttpClient, config: Config, + /// Registries whose configured credentials have been rejected, which are from + /// then on asked for anonymous tokens only. Re-offering credentials already + /// known to be bad costs a full retry budget per image and, on Docker Hub, + /// trips the failed-login throttle — whose 429s then delay the anonymous + /// request that would have worked. Config is only read at startup, so a + /// corrected token needs a restart anyway and this may live as long as the + /// client. + credentials_rejected: Mutex>, } impl Client { @@ -139,6 +135,7 @@ impl Client { Self { http_client, config, + credentials_rejected: Mutex::new(HashSet::new()), } } @@ -266,28 +263,6 @@ impl Client { } } - /// Returns the semver-parseable versions on the page, the URL of the next - /// page (if any), and the raw number of tags on the page (including - /// non-semver tags, used to budget the total scan). - fn parse_dockerhub_response( - &self, - response_text: &str, - ) -> Result<(Vec, Option, usize)> { - let dockerhub_response: DockerHubTagsResponse = serde_json::from_str(response_text) - .map_err(|e| anyhow!("Failed to parse Docker Hub response: {}", e))?; - - let raw_count = dockerhub_response.results.len(); - let mut versions = Vec::new(); - - for tag in dockerhub_response.results { - if let Some(version_info) = VersionInfo::from_tag(&tag.name) { - versions.push(version_info); - } - } - - Ok((versions, dockerhub_response.next, raw_count)) - } - /// Returns the semver-parseable versions on the page, the raw last tag (the /// `last=` cursor for the next page, regardless of whether it parsed as /// semver), and the raw number of tags on the page. @@ -318,65 +293,14 @@ impl Client { Ok((versions, raw_last_tag, raw_count)) } + /// Lists every tag of an image via the registry v2 API, including Docker Hub: + /// its hub.docker.com JSON API refuses anonymous requests past a 10,000-tag + /// offset ("pagination offset too large for anonymous requests"), which large + /// repos such as jellyfin/jellyfin (~13.6k tags) exceed. The v2 endpoint + /// paginates by cursor instead of offset, so it has no such ceiling, and its + /// 1000-tag pages need ~10x fewer requests. pub async fn get_available_versions(&self, image_ref: &ImageRef) -> Result> { - if image_ref.registry == "docker.io" { - self.get_dockerhub_versions(image_ref).await - } else { - self.get_registry_v2_versions(image_ref).await - } - } - - async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result> { - let repo_path = self.build_repository_path(image_ref); - let mut results = Vec::new(); - let mut tags_scanned: usize = 0; - let mut url = format!( - "https://hub.docker.com/v2/repositories/{repo_path}/tags/?page_size={DOCKERHUB_PAGE_SIZE}" - ); - - loop { - debug!("Docker Hub API URL: {}", url); - - let url_clone = url.clone(); - let fetched = self - .fetch_with_retry( - || async { self.http_client.get(&url_clone).send().await }, - "Docker Hub API request", - ) - .await?; - debug!("Docker Hub response status: {}", fetched.status); - - if !fetched.status.is_success() { - return Err(anyhow!( - "Docker Hub request failed with status {}: {}", - fetched.status, - fetched.body - )); - } - - let (new_tags, next_page, raw_count) = self.parse_dockerhub_response(&fetched.body)?; - - tags_scanned += raw_count; - results.extend(new_tags); - - if tags_scanned >= MAX_TAGS_SCANNED { - warn!( - "Reached the {}-tag scan limit for {} before exhausting the registry; \ - the newest version may have been missed", - MAX_TAGS_SCANNED, image_ref - ); - break; - } - - match next_page { - // Advance only on a non-empty page with a distinct next link; - // stop on the last page, an empty page, or a self-referential link. - Some(next) if raw_count > 0 && next != url => url = next, - _ => break, - } - } - - Ok(results) + self.get_registry_v2_versions(image_ref).await } async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result> { @@ -426,47 +350,56 @@ impl Client { ) .await?; - let (new_tags, raw_last_tag, raw_count, link_next) = - if fetched.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) = ®istry_config.auth_token { - if let Some(auth_header) = fetched.headers.get("www-authenticate") { - let auth_str = auth_header.to_str().map_err(|e| { - anyhow!("Invalid WWW-Authenticate header encoding: {}", e) - })?; - bearer_token = self.try_registry_v2_auth(auth_str, token).await?; - auth_attempts += 1; - continue; - } else { - return Err(anyhow!( - "Unauthorized request but no WWW-Authenticate header found" - )); - } - } else { - return Err(anyhow!("Unauthorized request but no auth token configured")); - } - } else if fetched.status.is_success() { - let link_next = fetched - .headers - .get("link") - .and_then(|h| h.to_str().ok()) - .and_then(|h| self.parse_link_header(h)); - - let (tags, raw_last_tag, raw_count) = self.parse_v2_response(&fetched.body)?; - (tags, raw_last_tag, raw_count, link_next) - } else { + let (new_tags, raw_last_tag, raw_count, link_next) = if fetched.status + == reqwest::StatusCode::UNAUTHORIZED + { + if auth_attempts >= MAX_AUTH_ATTEMPTS { return Err(anyhow!( - "Registry request failed with status {}: {}", - fetched.status, - fetched.body + "Authentication failed after {} attempts for registry {}", + auth_attempts, + image_ref.registry + )); + } + let Some(auth_header) = fetched.headers.get("www-authenticate") else { + return Err(anyhow!( + "Unauthorized request but no WWW-Authenticate header found" )); }; + let auth_str = auth_header + .to_str() + .map_err(|e| anyhow!("Invalid WWW-Authenticate header encoding: {}", e))?; + // Answer the challenge even with no token configured: public + // repositories on Docker Hub and ghcr.io hand out a pull-scoped + // token to unauthenticated callers, and the tag listing is + // inaccessible without one. + bearer_token = Some( + self.fetch_registry_v2_token(auth_str, &image_ref.registry, registry_config) + .await?, + ); + auth_attempts += 1; + continue; + } else if fetched.status.is_success() { + let link_next = fetched + .headers + .get("link") + .and_then(|h| h.to_str().ok()) + .and_then(|h| self.parse_link_header(h)); + + // A page came back, so the current token works: give the next + // 401 a fresh re-auth budget. Tokens expire (Docker Hub's last + // 5 minutes) and a long scan can outlive one, which must not + // exhaust the attempt cap and abandon the listing half-read. + auth_attempts = 0; + + let (tags, raw_last_tag, raw_count) = self.parse_v2_response(&fetched.body)?; + (tags, raw_last_tag, raw_count, link_next) + } else { + return Err(anyhow!( + "Registry request failed with status {}: {}", + fetched.status, + fetched.body + )); + }; tags_scanned += raw_count; results.extend(new_tags); @@ -506,7 +439,21 @@ impl Client { Ok(results) } - async fn try_registry_v2_auth(&self, auth_str: &str, token: &str) -> Result> { + /// Exchanges a `WWW-Authenticate` challenge for a bearer token, using the + /// registry's configured credentials when it has any. + /// + /// A rejected credential falls back to an anonymous token rather than failing + /// the image: public repositories hand out pull-scoped tokens to anyone, which + /// is all a tag listing needs. Docker Hub is why this matters — it validates + /// the basic-auth username, so a `dckr_pat_…` configured without a matching + /// `username` is refused outright, and without the fallback every Docker Hub + /// image in the run would fail. + async fn fetch_registry_v2_token( + &self, + auth_str: &str, + registry: &str, + registry_config: &RegistryConfig, + ) -> Result { let realm = extract_auth_param(auth_str, "realm")?; let service = extract_auth_param(auth_str, "service")?; let scope = extract_auth_param(auth_str, "scope")?; @@ -516,18 +463,76 @@ impl Client { urlencoding::encode(&service), urlencoding::encode(&scope), ); - debug!("Getting registry token from: {}", auth_url); - let auth_url_clone = auth_url.clone(); - let token_clone = token.to_string(); + let credentials = registry_config + .auth_token + .as_ref() + .filter(|_| !self.has_rejected_credentials(registry)) + .map(|token| { + ( + registry_config + .username + .as_deref() + .unwrap_or(DEFAULT_AUTH_USERNAME) + .to_string(), + token.clone(), + ) + }); + + if credentials.is_none() { + return self.request_registry_v2_token(&auth_url, None).await; + } + + match self.request_registry_v2_token(&auth_url, credentials).await { + Ok(token) => Ok(token), + Err(e) => { + warn!( + "Credentialed auth for {} failed ({:#}); using anonymous tokens for it \ + from now on. Private repositories there will be unreadable until the \ + registry's `auth_token` (and, for Docker Hub, `username`) is corrected", + registry, e + ); + self.credentials_rejected + .lock() + .expect("credentials_rejected mutex poisoned") + .insert(registry.to_string()); + self.request_registry_v2_token(&auth_url, None).await + } + } + } + + fn has_rejected_credentials(&self, registry: &str) -> bool { + self.credentials_rejected + .lock() + .expect("credentials_rejected mutex poisoned") + .contains(registry) + } + + /// Requests a bearer token from a token endpoint, anonymously when + /// `credentials` is `None`. + async fn request_registry_v2_token( + &self, + auth_url: &str, + credentials: Option<(String, String)>, + ) -> Result { + debug!( + "Getting {} registry token from: {}", + if credentials.is_some() { + "authenticated" + } else { + "anonymous" + }, + auth_url + ); + let token_response = self .fetch_with_retry( || async { - self.http_client - .get(&auth_url_clone) - .basic_auth("token", Some(&token_clone)) - .send() - .await + let mut request_builder = self.http_client.get(auth_url); + if let Some((username, token)) = &credentials { + request_builder = request_builder.basic_auth(username, Some(token)); + } + request_builder.send().await }, "Registry auth token request", ) @@ -545,7 +550,7 @@ impl Client { .map_err(|e| anyhow!("Failed to parse auth token response: {}", e))?; if let Some(registry_token) = token_json.get("token").and_then(|t| t.as_str()) { - return Ok(Some(registry_token.to_string())); + return Ok(registry_token.to_string()); } Err(anyhow!("Auth response missing 'token' field")) @@ -749,19 +754,6 @@ mod tests { } } - #[test] - fn test_parse_dockerhub_response_reports_raw_count_and_next() { - let client = Client::new(Config::default()); - - let body = r#"{"count":3,"next":"https://hub.docker.com/v2/repositories/jellyfin/jellyfin/tags/?page=2&page_size=100","results":[{"name":"10.10.7"},{"name":"latest"},{"name":"10.10.6"}]}"#; - let (versions, next, raw_count) = client.parse_dockerhub_response(body).unwrap(); - - assert_eq!(raw_count, 3); - assert!(next.unwrap().contains("page=2")); - let parsed: Vec<_> = versions.iter().map(|v| v.original.as_str()).collect(); - assert_eq!(parsed, vec!["10.10.7", "10.10.6"]); - } - #[test] fn test_parse_link_header() { let config = Config::default(); diff --git a/src/strategy.rs b/src/strategy.rs index f45251b..3704527 100644 --- a/src/strategy.rs +++ b/src/strategy.rs @@ -1,6 +1,6 @@ use crate::config::UpdateStrategy; use crate::version::VersionInfo; -use tracing::{debug, warn}; +use tracing::debug; pub fn create_selector(strategy: &UpdateStrategy) -> Box { match strategy { @@ -34,8 +34,6 @@ impl VersionSelector for LatestVersionSelector { if let Some(ref selected) = latest { debug!("Latest version selected: {}", selected.version); - } else { - warn!("No matching versions available"); } latest @@ -55,29 +53,26 @@ impl VersionSelector for SmartPreviousMinorSelector { get_filtered_and_sorted_matching_versions(available, current_prefix, current_suffix); let latest = &versions.first()?.version; + let latest_line = (latest.major, latest.minor); debug!("Latest version available: {}", latest); - let (target_major, max_minor) = if latest.minor == 0 { - if latest.major == 0 { - debug!("Cannot go to previous version of 0.0.x"); - return None; - } - (latest.major - 1, None) - } else { - (latest.major, Some(latest.minor - 1)) - }; - + // The target is the highest patch of the newest release line strictly + // below the latest one. Deriving that line by decrementing (`minor - 1`, + // or `major - 1` when the latest is a `.0`) assumes releases never skip a + // number, which registries routinely do: linuxserver/sonarr publishes 5.14 + // and 4.0.x with no 5.13, so a decrement searched for a 5.x line that does + // not exist and returned nothing, stranding the image on its current tag. + // Since `versions` is sorted descending, the first entry below the latest + // line is exactly that. let selected = versions .iter() - .find(|v| { - v.version.major == target_major && max_minor.is_none_or(|m| v.version.minor <= m) - }) + .find(|v| (v.version.major, v.version.minor) < latest_line) .cloned(); if let Some(ref selected) = selected { debug!("Selected version: {}", selected.version); } else { - warn!("No matching versions available for previous minor strategy"); + debug!("No release line below {} to fall back to", latest); } selected @@ -199,6 +194,81 @@ mod tests { ); } + /// linuxserver/sonarr: the newest line is 5.14 with no 5.x below it, so the + /// previous line is the 4.0 series. Decrementing the minor used to look for a + /// nonexistent 5.13-or-lower and leave the image stuck on 4.0.15. + #[test] + fn test_previous_minor_steps_across_a_major_gap() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let available = vec![ + VersionInfo::from_tag("5.14").unwrap(), + VersionInfo::from_tag("4.0.19").unwrap(), + VersionInfo::from_tag("4.0.18").unwrap(), + VersionInfo::from_tag("4.0.15").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("4.0.19").unwrap()) + ); + } + + /// linuxserver/qbittorrent: a stray legacy 14.3.9 tag sorts above every real + /// release, and there is no 14.x line below it. The previous line is 5.2. + #[test] + fn test_previous_minor_skips_an_isolated_outlier_line() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let available = vec![ + VersionInfo::from_tag("14.3.9").unwrap(), + VersionInfo::from_tag("5.2.3").unwrap(), + VersionInfo::from_tag("5.2.0").unwrap(), + VersionInfo::from_tag("5.1.4").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("5.2.3").unwrap()) + ); + } + + /// Skipped minors (2.5 -> 2.3, no 2.4) must not strand the image either. + #[test] + fn test_previous_minor_handles_skipped_minors() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let available = vec![ + VersionInfo::from_tag("2.5.2").unwrap(), + VersionInfo::from_tag("2.3.5").unwrap(), + VersionInfo::from_tag("2.3.0").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("2.3.5").unwrap()) + ); + } + + /// A single release line has nothing below it: staying put is correct, since + /// the strategy exists to keep one line behind the newest. + #[test] + fn test_previous_minor_without_an_older_line() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let available = vec![ + VersionInfo::from_tag("1.0.2").unwrap(), + VersionInfo::from_tag("1.0.1").unwrap(), + ]; + + assert!(selector + .select_target_version(&available, None, None) + .is_none()); + } + #[test] fn test_cross_major_version_handling() { let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 8f598ff..c59fbeb 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -177,6 +177,7 @@ async fn test_ghcr_authentication_e2e() { RegistryConfig { url: "https://registry-1.docker.io".to_string(), auth_token: None, + username: None, }, ); registries.insert( @@ -184,6 +185,7 @@ async fn test_ghcr_authentication_e2e() { RegistryConfig { url: "https://ghcr.io".to_string(), auth_token: Some(github_token), + username: None, }, ); @@ -214,6 +216,7 @@ fn create_test_config() -> Config { RegistryConfig { url: "https://registry-1.docker.io".to_string(), auth_token: None, + username: None, }, ); diff --git a/tests/test_compose_operations.rs b/tests/test_compose_operations.rs index efc6fbe..7dbf76e 100644 --- a/tests/test_compose_operations.rs +++ b/tests/test_compose_operations.rs @@ -488,6 +488,7 @@ fn create_test_config() -> Config { RegistryConfig { url: "https://registry-1.docker.io".to_string(), auth_token: None, + username: None, }, ); registries.insert( @@ -495,6 +496,7 @@ fn create_test_config() -> Config { RegistryConfig { url: "https://ghcr.io".to_string(), auth_token: None, + username: None, }, ); diff --git a/tests/test_config_validation.rs b/tests/test_config_validation.rs index a3a8787..0be83b7 100644 --- a/tests/test_config_validation.rs +++ b/tests/test_config_validation.rs @@ -183,6 +183,7 @@ fn test_registry_configuration() { RegistryConfig { url: "https://simple.registry.com".to_string(), auth_token: None, + username: None, }, ); registries.insert( @@ -190,6 +191,7 @@ fn test_registry_configuration() { RegistryConfig { url: "https://auth.registry.com".to_string(), auth_token: Some("secret-token".to_string()), + username: None, }, ); @@ -224,7 +226,11 @@ registries: {} assert!(config.compose_paths.is_empty()); assert!(config.ignore_images.is_empty()); assert!(config.schedule.is_empty()); - assert!(config.registries.is_empty()); + // The other fields stay as written, but the default registries are restored: + // resolving an image now goes through its registry entry, so an empty map + // would make every service fail with "Unknown registry" rather than express + // any useful intent. + assert!(config.registries.contains_key("docker.io")); } #[test] @@ -283,3 +289,50 @@ registries: .auth_token .is_none()); } + +#[test] +fn test_load_restores_default_registries_omitted_by_the_config_file() { + // A `registries` block replaces the defaults instead of merging, so a file + // listing only a private registry must still be able to resolve Docker Hub: + // every `nginx:1.2.3`-style image in the compose files depends on it. + let yaml_config = r#" +compose_paths: [] +schedule: "0 0 2 * * *" +registries: + private.registry.com: + url: "https://private.registry.com" +"#; + + 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_eq!( + config.registries["docker.io"].url, + "https://registry-1.docker.io" + ); + assert!(config.registries.contains_key("private.registry.com")); +} + +#[test] +fn test_load_keeps_explicit_registry_overrides() { + let yaml_config = r#" +compose_paths: [] +schedule: "0 0 2 * * *" +registries: + docker.io: + url: "https://mirror.internal" + username: "andras" + auth_token: "secret" +"#; + + 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(); + let docker_io = &config.registries["docker.io"]; + assert_eq!(docker_io.url, "https://mirror.internal"); + assert_eq!(docker_io.username.as_deref(), Some("andras")); +} diff --git a/tests/test_error_handling.rs b/tests/test_error_handling.rs index f633aa2..87f771c 100644 --- a/tests/test_error_handling.rs +++ b/tests/test_error_handling.rs @@ -288,6 +288,7 @@ fn create_test_config() -> Config { RegistryConfig { url: "https://registry-1.docker.io".to_string(), auth_token: None, + username: None, }, );