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 * * *"
|
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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: ®ex::Captures| {
|
.replace_all(content, |caps: ®ex::Captures| {
|
||||||
|
|
|
||||||
322
src/registry.rs
322
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 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) = ®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 {
|
|
||||||
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();
|
||||||
|
|
|
||||||
104
src/strategy.rs
104
src/strategy.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue