Add username
All checks were successful
Build and Publish Docker Image / test (push) Successful in 1m43s
Build and Publish Docker Image / build-and-push (push) Successful in 1m18s

This commit is contained in:
Andras Schmelczer 2026-08-08 21:32:27 +01:00
commit 07e582e2be
9 changed files with 358 additions and 192 deletions

View file

@ -12,16 +12,26 @@ compose_paths:
schedule: "0 0 2 * * *" schedule: "0 0 2 * * *"
# Registry configurations # 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: registries:
"docker.io": "docker.io":
url: "https://registry-1.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": "ghcr.io":
url: "https://ghcr.io" url: "https://ghcr.io"
auth_token: "${GITHUB_TOKEN}" auth_token: "${GITHUB_TOKEN}"
# GitHub token must have 'read:packages' scope and access to the target repositories # 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": "registry.gitlab.com":
url: "https://registry.gitlab.com" url: "https://registry.gitlab.com"
@ -37,10 +47,6 @@ registries:
update_strategy: "LatestPatchOfPreviousMinor" update_strategy: "LatestPatchOfPreviousMinor"
# Images to ignore (substring matching) # Images to ignore (substring matching)
ignore_images: ignore_images: []
- "localhost"
- "127.0.0.1"
- "local/"
# Dry run mode - if true, no files will be modified
dry_run: false dry_run: false

View file

@ -296,10 +296,27 @@ fn choose_new_tag(
strategy: &UpdateStrategy, strategy: &UpdateStrategy,
) -> Option<String> { ) -> Option<String> {
let selector = create_selector(strategy); let selector = create_selector(strategy);
let target = // Warn here rather than inside the selector: this is the innermost scope that
selector.select_target_version(available_versions, current_prefix, current_suffix)?; // 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 { if target.version <= *current_version {
debug!("{} is already at or above the target {}", image_ref, target);
return None; return None;
} }

View file

@ -24,8 +24,17 @@ pub struct Config {
pub struct RegistryConfig { pub struct RegistryConfig {
pub url: String, pub url: String,
pub auth_token: Option<String>, pub auth_token: Option<String>,
/// 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<String>,
} }
/// 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum UpdateStrategy { pub enum UpdateStrategy {
#[default] #[default]
@ -41,6 +50,7 @@ impl Default for Config {
RegistryConfig { RegistryConfig {
url: "https://registry-1.docker.io".to_string(), url: "https://registry-1.docker.io".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );
registries.insert( registries.insert(
@ -48,6 +58,7 @@ impl Default for Config {
RegistryConfig { RegistryConfig {
url: "https://ghcr.io".to_string(), url: "https://ghcr.io".to_string(),
auth_token: std::env::var("GITHUB_TOKEN").ok(), 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 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.normalize_auth_tokens(); config.normalize_auth_tokens();
config.restore_missing_default_registries();
Ok(config) 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 { pub fn expand_env_vars(content: &str) -> String {
ENV_VAR_REGEX ENV_VAR_REGEX
.replace_all(content, |caps: &regex::Captures| { .replace_all(content, |caps: &regex::Captures| {

View file

@ -1,25 +1,24 @@
use crate::config::{Config, RegistryConfig}; use crate::config::{Config, RegistryConfig, DEFAULT_AUTH_USERNAME};
use crate::version::VersionInfo; use crate::version::VersionInfo;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use reqwest::{header::HeaderMap, Client as HttpClient, Response, StatusCode}; use reqwest::{header::HeaderMap, Client as HttpClient, Response, StatusCode};
use serde::Deserialize; use std::collections::HashSet;
use std::sync::LazyLock; use std::sync::{LazyLock, Mutex};
use std::time::Duration; use std::time::Duration;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{debug, warn}; use tracing::{debug, warn};
// Docker Hub silently clamps page_size to 100, so request exactly that. // Registries implementing the distribution spec (Docker Hub, ghcr.io, lscr.io)
const DOCKERHUB_PAGE_SIZE: usize = 100; // cap `n` at 1000; a larger value is clamped, so 1000 minimises the number of
// ghcr.io / lscr.io (and distribution-spec registries) cap `n` at 1000; a larger // requests (and thus 429s).
// value is clamped, so 1000 minimises the number of requests (and thus 429s).
const OCI_PAGE_SIZE: usize = 1000; const OCI_PAGE_SIZE: usize = 1000;
// Upper bound on tags fetched per image, as a runaway guard only. Tag listings on // Upper bound on tags fetched per image, as a runaway guard only. Tag listings are
// Docker Hub and ghcr.io/lscr.io are NOT ordered by version (Docker Hub's order is // NOT ordered by version (Docker Hub's registry API is lexical, ghcr.io/lscr.io are
// undefined; ghcr.io/lscr.io are chronological), so we must scan the whole list to // chronological), so we must scan the whole list to reliably find the highest
// reliably find the highest version rather than truncate and risk missing it. Real // version rather than truncate and risk missing it. Real repos (e.g.
// repos (e.g. jellyfin/jellyfin at ~13.6k tags) finish far below this; hitting it // jellyfin/jellyfin at ~13.6k tags) finish far below this; hitting it means the
// means the result may be incomplete and is logged loudly. // result may be incomplete and is logged loudly.
const MAX_TAGS_SCANNED: usize = 50_000; const MAX_TAGS_SCANNED: usize = 50_000;
const MAX_RETRY_ATTEMPTS: u32 = 5; const MAX_RETRY_ATTEMPTS: u32 = 5;
const MAX_AUTH_ATTEMPTS: u32 = 2; const MAX_AUTH_ATTEMPTS: u32 = 2;
@ -101,17 +100,6 @@ impl std::fmt::Display for ImageRef {
} }
} }
#[derive(Deserialize)]
struct DockerHubTagsResponse {
results: Vec<DockerHubTag>,
next: Option<String>,
}
#[derive(Deserialize)]
struct DockerHubTag {
name: String,
}
/// A fully-read HTTP response. The body is read inside the retry scope so that a /// 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; /// connection dropped mid-body is retried like any other transport failure;
/// callers therefore receive the body as an owned string rather than a streaming /// callers therefore receive the body as an owned string rather than a streaming
@ -125,6 +113,14 @@ struct FetchResult {
pub struct Client { pub struct Client {
http_client: HttpClient, http_client: HttpClient,
config: Config, 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<HashSet<String>>,
} }
impl Client { impl Client {
@ -139,6 +135,7 @@ impl Client {
Self { Self {
http_client, http_client,
config, 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<VersionInfo>, Option<String>, 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 /// 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 /// `last=` cursor for the next page, regardless of whether it parsed as
/// semver), and the raw number of tags on the page. /// semver), and the raw number of tags on the page.
@ -318,65 +293,14 @@ impl Client {
Ok((versions, raw_last_tag, raw_count)) 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<Vec<VersionInfo>> { pub async fn get_available_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
if image_ref.registry == "docker.io" { self.get_registry_v2_versions(image_ref).await
self.get_dockerhub_versions(image_ref).await
} else {
self.get_registry_v2_versions(image_ref).await
}
}
async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
let repo_path = self.build_repository_path(image_ref);
let mut results = Vec::new();
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)
} }
async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> { async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
@ -426,47 +350,56 @@ impl Client {
) )
.await?; .await?;
let (new_tags, raw_last_tag, raw_count, link_next) = let (new_tags, raw_last_tag, raw_count, link_next) = if fetched.status
if fetched.status == reqwest::StatusCode::UNAUTHORIZED { == reqwest::StatusCode::UNAUTHORIZED
if auth_attempts >= MAX_AUTH_ATTEMPTS { {
return Err(anyhow!( if auth_attempts >= MAX_AUTH_ATTEMPTS {
"Authentication failed after {} attempts for registry {}",
auth_attempts,
image_ref.registry
));
}
if let Some(token) = &registry_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 {
return Err(anyhow!( return Err(anyhow!(
"Registry request failed with status {}: {}", "Authentication failed after {} attempts for registry {}",
fetched.status, auth_attempts,
fetched.body 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; tags_scanned += raw_count;
results.extend(new_tags); results.extend(new_tags);
@ -506,7 +439,21 @@ impl Client {
Ok(results) Ok(results)
} }
async fn try_registry_v2_auth(&self, auth_str: &str, token: &str) -> Result<Option<String>> { /// 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<String> {
let realm = extract_auth_param(auth_str, "realm")?; let realm = extract_auth_param(auth_str, "realm")?;
let service = extract_auth_param(auth_str, "service")?; let service = extract_auth_param(auth_str, "service")?;
let scope = extract_auth_param(auth_str, "scope")?; let scope = extract_auth_param(auth_str, "scope")?;
@ -516,18 +463,76 @@ impl Client {
urlencoding::encode(&service), urlencoding::encode(&service),
urlencoding::encode(&scope), urlencoding::encode(&scope),
); );
debug!("Getting registry token from: {}", auth_url);
let auth_url_clone = auth_url.clone(); let credentials = registry_config
let token_clone = token.to_string(); .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<String> {
debug!(
"Getting {} registry token from: {}",
if credentials.is_some() {
"authenticated"
} else {
"anonymous"
},
auth_url
);
let token_response = self let token_response = self
.fetch_with_retry( .fetch_with_retry(
|| async { || async {
self.http_client let mut request_builder = self.http_client.get(auth_url);
.get(&auth_url_clone) if let Some((username, token)) = &credentials {
.basic_auth("token", Some(&token_clone)) request_builder = request_builder.basic_auth(username, Some(token));
.send() }
.await request_builder.send().await
}, },
"Registry auth token request", "Registry auth token request",
) )
@ -545,7 +550,7 @@ impl Client {
.map_err(|e| anyhow!("Failed to parse auth token response: {}", e))?; .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()) { 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")) 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] #[test]
fn test_parse_link_header() { fn test_parse_link_header() {
let config = Config::default(); let config = Config::default();

View file

@ -1,6 +1,6 @@
use crate::config::UpdateStrategy; use crate::config::UpdateStrategy;
use crate::version::VersionInfo; use crate::version::VersionInfo;
use tracing::{debug, warn}; use tracing::debug;
pub fn create_selector(strategy: &UpdateStrategy) -> Box<dyn VersionSelector> { pub fn create_selector(strategy: &UpdateStrategy) -> Box<dyn VersionSelector> {
match strategy { match strategy {
@ -34,8 +34,6 @@ impl VersionSelector for LatestVersionSelector {
if let Some(ref selected) = latest { if let Some(ref selected) = latest {
debug!("Latest version selected: {}", selected.version); debug!("Latest version selected: {}", selected.version);
} else {
warn!("No matching versions available");
} }
latest latest
@ -55,29 +53,26 @@ impl VersionSelector for SmartPreviousMinorSelector {
get_filtered_and_sorted_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;
let latest_line = (latest.major, latest.minor);
debug!("Latest version available: {}", latest); debug!("Latest version available: {}", latest);
let (target_major, max_minor) = if latest.minor == 0 { // The target is the highest patch of the newest release line strictly
if latest.major == 0 { // below the latest one. Deriving that line by decrementing (`minor - 1`,
debug!("Cannot go to previous version of 0.0.x"); // or `major - 1` when the latest is a `.0`) assumes releases never skip a
return None; // 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
(latest.major - 1, None) // not exist and returned nothing, stranding the image on its current tag.
} else { // Since `versions` is sorted descending, the first entry below the latest
(latest.major, Some(latest.minor - 1)) // line is exactly that.
};
let selected = versions let selected = versions
.iter() .iter()
.find(|v| { .find(|v| (v.version.major, v.version.minor) < latest_line)
v.version.major == target_major && max_minor.is_none_or(|m| v.version.minor <= m)
})
.cloned(); .cloned();
if let Some(ref selected) = selected { if let Some(ref selected) = selected {
debug!("Selected version: {}", selected.version); debug!("Selected version: {}", selected.version);
} else { } else {
warn!("No matching versions available for previous minor strategy"); debug!("No release line below {} to fall back to", latest);
} }
selected 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] #[test]
fn test_cross_major_version_handling() { fn test_cross_major_version_handling() {
let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor);

View file

@ -177,6 +177,7 @@ async fn test_ghcr_authentication_e2e() {
RegistryConfig { RegistryConfig {
url: "https://registry-1.docker.io".to_string(), url: "https://registry-1.docker.io".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );
registries.insert( registries.insert(
@ -184,6 +185,7 @@ async fn test_ghcr_authentication_e2e() {
RegistryConfig { RegistryConfig {
url: "https://ghcr.io".to_string(), url: "https://ghcr.io".to_string(),
auth_token: Some(github_token), auth_token: Some(github_token),
username: None,
}, },
); );
@ -214,6 +216,7 @@ fn create_test_config() -> Config {
RegistryConfig { RegistryConfig {
url: "https://registry-1.docker.io".to_string(), url: "https://registry-1.docker.io".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );

View file

@ -488,6 +488,7 @@ fn create_test_config() -> Config {
RegistryConfig { RegistryConfig {
url: "https://registry-1.docker.io".to_string(), url: "https://registry-1.docker.io".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );
registries.insert( registries.insert(
@ -495,6 +496,7 @@ fn create_test_config() -> Config {
RegistryConfig { RegistryConfig {
url: "https://ghcr.io".to_string(), url: "https://ghcr.io".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );

View file

@ -183,6 +183,7 @@ fn test_registry_configuration() {
RegistryConfig { RegistryConfig {
url: "https://simple.registry.com".to_string(), url: "https://simple.registry.com".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );
registries.insert( registries.insert(
@ -190,6 +191,7 @@ fn test_registry_configuration() {
RegistryConfig { RegistryConfig {
url: "https://auth.registry.com".to_string(), url: "https://auth.registry.com".to_string(),
auth_token: Some("secret-token".to_string()), auth_token: Some("secret-token".to_string()),
username: None,
}, },
); );
@ -224,7 +226,11 @@ registries: {}
assert!(config.compose_paths.is_empty()); assert!(config.compose_paths.is_empty());
assert!(config.ignore_images.is_empty()); assert!(config.ignore_images.is_empty());
assert!(config.schedule.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] #[test]
@ -283,3 +289,50 @@ registries:
.auth_token .auth_token
.is_none()); .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"));
}

View file

@ -288,6 +288,7 @@ fn create_test_config() -> Config {
RegistryConfig { RegistryConfig {
url: "https://registry-1.docker.io".to_string(), url: "https://registry-1.docker.io".to_string(),
auth_token: None, auth_token: None,
username: None,
}, },
); );