Add username
This commit is contained in:
parent
71654a6fe2
commit
07e582e2be
9 changed files with 358 additions and 192 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -296,10 +296,27 @@ fn choose_new_tag(
|
|||
strategy: &UpdateStrategy,
|
||||
) -> Option<String> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,17 @@ pub struct Config {
|
|||
pub struct RegistryConfig {
|
||||
pub url: 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)]
|
||||
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| {
|
||||
|
|
|
|||
274
src/registry.rs
274
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<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
|
||||
/// 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<HashSet<String>>,
|
||||
}
|
||||
|
||||
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<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
|
||||
/// `last=` cursor for the next page, regardless of whether it parsed as
|
||||
/// semver), and the raw number of tags on the page.
|
||||
|
|
@ -318,66 +293,15 @@ 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<Vec<VersionInfo>> {
|
||||
if image_ref.registry == "docker.io" {
|
||||
self.get_dockerhub_versions(image_ref).await
|
||||
} else {
|
||||
self.get_registry_v2_versions(image_ref).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
|
||||
let repo_path = self.build_repository_path(image_ref);
|
||||
let mut results = Vec::new();
|
||||
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>> {
|
||||
let registry_config = self.get_registry_config(&image_ref.registry)?;
|
||||
|
|
@ -426,8 +350,9 @@ impl Client {
|
|||
)
|
||||
.await?;
|
||||
|
||||
let (new_tags, raw_last_tag, raw_count, link_next) =
|
||||
if fetched.status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
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 {}",
|
||||
|
|
@ -435,22 +360,24 @@ impl Client {
|
|||
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 {
|
||||
let Some(auth_header) = fetched.headers.get("www-authenticate") else {
|
||||
return Err(anyhow!(
|
||||
"Unauthorized request but no WWW-Authenticate header found"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow!("Unauthorized request but no auth token configured"));
|
||||
}
|
||||
};
|
||||
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
|
||||
|
|
@ -458,6 +385,12 @@ impl Client {
|
|||
.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 {
|
||||
|
|
@ -506,7 +439,21 @@ impl Client {
|
|||
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 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<String> {
|
||||
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();
|
||||
|
|
|
|||
104
src/strategy.rs
104
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<dyn VersionSelector> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ fn create_test_config() -> Config {
|
|||
RegistryConfig {
|
||||
url: "https://registry-1.docker.io".to_string(),
|
||||
auth_token: None,
|
||||
username: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue