docker-compose-updater/src/registry.rs
Andras Schmelczer 60c744d381
All checks were successful
Build and Publish Docker Image / test (push) Successful in 41s
Build and Publish Docker Image / build-and-push (push) Successful in 55s
Longer retries
2026-09-27 15:02:27 +01:00

1010 lines
39 KiB
Rust

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 std::collections::{HashMap, HashSet};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use tokio::time::{sleep, sleep_until, Instant};
use tracing::{debug, warn};
// 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 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;
const INITIAL_RETRY_DELAY_SECS: u64 = 1;
const INITIAL_RATE_LIMIT_DELAY_SECS: u64 = 5;
const HTTP_TIMEOUT_SECS: u64 = 30;
const HTTP_CONNECT_TIMEOUT_SECS: u64 = 10;
// Evict idle keep-alive connections well before registries (Docker Hub, GHCR,
// lscr.io) close them server-side. Reusing a connection the server has already
// dropped is what surfaces as "connection closed before message completed";
// recycling early makes that race rare, and `fetch_with_retry` covers the rest.
const POOL_IDLE_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone)]
pub struct ImageRef {
pub registry: String,
pub namespace: Option<String>,
pub name: String,
pub tag: String,
}
impl ImageRef {
pub fn parse(image: &str) -> Result<Self> {
let (image_part, tag) = if let Some(last_delim) = image.rfind(':') {
(&image[..last_delim], &image[last_delim + 1..])
} else {
(image, "latest")
};
let registry_parts: Vec<&str> = image_part.split('/').collect();
let (registry, namespace, name) = match registry_parts.len() {
0 => return Err(anyhow!("Invalid image format: {}", image)),
1 => ("docker.io".to_string(), None, registry_parts[0].to_string()),
2 => {
if registry_parts[0].contains('.') || registry_parts[0].contains(':') {
(
registry_parts[0].to_string(),
None,
registry_parts[1].to_string(),
)
} else {
(
"docker.io".to_string(),
Some(registry_parts[0].to_string()),
registry_parts[1].to_string(),
)
}
}
_ => {
let registry = registry_parts[0].to_string();
let name = registry_parts[registry_parts.len() - 1].to_string();
let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/"));
(registry, namespace, name)
}
};
Ok(ImageRef {
registry,
namespace,
name,
tag: tag.to_string(),
})
}
}
impl std::fmt::Display for ImageRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.registry == "docker.io" {
match &self.namespace {
Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag),
None => write!(f, "{}:{}", self.name, self.tag),
}
} else {
match &self.namespace {
Some(ns) => write!(f, "{}/{}/{}:{}", self.registry, ns, self.name, self.tag),
None => write!(f, "{}/{}:{}", self.registry, self.name, self.tag),
}
}
}
}
/// 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
/// `Response`, while retaining the status and headers they need.
struct FetchResult {
status: StatusCode,
headers: HeaderMap,
body: String,
}
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>>,
/// Shared across pages and images so a successful request does not reset
/// pacing, and exhausting one image's retries does not bypass a cooldown.
next_request_at: Mutex<HashMap<String, Instant>>,
}
impl Client {
pub fn new(config: Config) -> Self {
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECS))
.pool_idle_timeout(Duration::from_secs(POOL_IDLE_TIMEOUT_SECS))
.build()
.expect("failed to build HTTP client");
Self {
http_client,
config,
credentials_rejected: Mutex::new(HashSet::new()),
next_request_at: Mutex::new(HashMap::new()),
}
}
async fn wait_for_request_slot(&self, backend: &str) {
loop {
let deadline = {
let mut next_requests = self.next_request_at.lock().unwrap();
let now = Instant::now();
let next = next_requests.entry(backend.to_string()).or_insert(now);
if *next <= now {
*next = now + Duration::from_millis(self.config.registry_request_interval_ms);
return;
}
*next
};
sleep_until(deadline).await;
// Recheck in case another request extended the cooldown.
}
}
fn defer_requests(&self, backend: &str, delay: Duration) {
let deadline = Instant::now() + delay;
let mut next_requests = self.next_request_at.lock().unwrap();
let next = next_requests.entry(backend.to_string()).or_insert(deadline);
*next = (*next).max(deadline);
}
fn parse_retry_after(&self, retry_after: &str) -> Option<Duration> {
if let Ok(seconds) = retry_after.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
if let Ok(date) = DateTime::parse_from_rfc2822(retry_after) {
let now = Utc::now();
let wait_time = date.signed_duration_since(now);
if wait_time.num_seconds() > 0 {
return Some(Duration::from_secs(wait_time.num_seconds() as u64));
}
}
None
}
async fn fetch_with_retry<F, Fut>(
&self,
request_fn: F,
operation_name: &str,
request_url: &str,
) -> Result<FetchResult>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<Response, reqwest::Error>>,
{
let mut attempt = 0;
let mut delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS);
let mut rate_limit_delay = Duration::from_secs(INITIAL_RATE_LIMIT_DELAY_SECS);
let backend = registry_backend(request_url)?;
let operation_name = format!("{operation_name} [{backend}]");
loop {
self.wait_for_request_slot(&backend).await;
// Send the request AND read the body as one retryable unit: a
// connection dropped while streaming the body (`is_body`) is just as
// transient as one dropped while sending the request (`is_request`),
// and reading it here lets both be retried.
let fetched = async {
let response = request_fn().await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await?;
Ok::<_, reqwest::Error>(FetchResult {
status,
headers,
body,
})
}
.await;
let fetched = match fetched {
Ok(fetched) => fetched,
Err(err) => {
attempt += 1;
// Transient transport failures (connect timeouts, dropped
// keep-alive connections) never reached this retry path
// before and aborted the whole update run. Retry them with
// the same bounded, exponential backoff used for 429s.
if !is_transient_transport_error(&err) || attempt > MAX_RETRY_ATTEMPTS {
return Err(
anyhow::Error::new(err).context(format!("{operation_name} failed"))
);
}
warn!(
"{} transient transport error ({}), attempt {}/{}, waiting {:?} before retry",
operation_name, err, attempt, MAX_RETRY_ATTEMPTS, delay
);
sleep(delay).await;
delay = delay.saturating_mul(2);
continue;
}
};
if fetched.status == StatusCode::TOO_MANY_REQUESTS {
attempt += 1;
let wait_duration = if let Some(retry_after) = fetched
.headers
.get("retry-after")
.and_then(|h| h.to_str().ok())
{
self.parse_retry_after(retry_after)
.unwrap_or(rate_limit_delay)
} else {
rate_limit_delay
};
self.defer_requests(&backend, wait_duration);
if attempt > MAX_RETRY_ATTEMPTS {
return Err(anyhow!(
"{} failed: Rate limited (429) after {} retries; backend cooldown {:?}",
operation_name,
MAX_RETRY_ATTEMPTS,
wait_duration
));
}
warn!(
"{} rate limited (429), retry {}/{}, waiting {:?} before retry",
operation_name, attempt, MAX_RETRY_ATTEMPTS, wait_duration
);
rate_limit_delay = rate_limit_delay.saturating_mul(2);
} else {
return Ok(fetched);
}
}
}
fn get_registry_config(&self, registry: &str) -> Result<&RegistryConfig> {
self.config
.registries
.get(registry)
.ok_or_else(|| anyhow!("Unknown registry: {}", registry))
}
fn build_repository_path(&self, image_ref: &ImageRef) -> String {
match &image_ref.namespace {
Some(namespace) => format!("{}/{}", namespace, image_ref.name),
None if image_ref.registry == "docker.io" => {
format!("library/{}", image_ref.name)
}
None => image_ref.name.clone(),
}
}
/// 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.
fn parse_v2_response(
&self,
response_text: &str,
) -> Result<(Vec<VersionInfo>, Option<String>, usize)> {
debug!("Parsing registry v2 response: {}", response_text);
let tags_response: serde_json::Value = serde_json::from_str(response_text)
.map_err(|e| anyhow!("Failed to parse registry response: {}", e))?;
let mut versions = Vec::new();
let mut raw_last_tag = None;
let mut raw_count = 0;
if let Some(tags) = tags_response.get("tags").and_then(|t| t.as_array()) {
for tag in tags {
if let Some(tag_str) = tag.as_str() {
raw_count += 1;
raw_last_tag = Some(tag_str.to_string());
if let Some(version_info) = VersionInfo::from_tag(tag_str) {
versions.push(version_info);
}
}
}
}
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>> {
self.get_registry_v2_versions(image_ref).await
}
async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result<Vec<VersionInfo>> {
let registry_config = self.get_registry_config(&image_ref.registry)?;
let repo_path = self.build_repository_path(image_ref);
let mut results = Vec::new();
let mut last_tag: Option<String> = None;
let mut next_url: Option<String> = None;
let mut bearer_token: Option<String> = None;
let mut auth_attempts: u32 = 0;
let mut tags_scanned: usize = 0;
loop {
let url = if let Some(next) = next_url.take() {
if next.starts_with("http://") || next.starts_with("https://") {
next
} else {
format!("{}{next}", registry_config.url)
}
} else {
format!(
"{}/v2/{repo_path}/tags/list?n={OCI_PAGE_SIZE}{}",
registry_config.url,
if let Some(ref last) = last_tag {
format!("&last={last}")
} else {
String::new()
}
)
};
debug!("Registry API URL: {}", url);
let url_clone = url.clone();
let bearer_token_clone = bearer_token.clone();
let fetched = self
.fetch_with_retry(
|| async {
let mut request_builder = self.http_client.get(&url_clone);
if let Some(token) = &bearer_token_clone {
request_builder = request_builder.bearer_auth(token);
}
request_builder.send().await
},
&format!("Registry v2 tag listing for {image_ref}"),
&url,
)
.await?;
let (new_tags, raw_last_tag, raw_count, link_next) = if fetched.status
== reqwest::StatusCode::UNAUTHORIZED
{
if auth_attempts >= MAX_AUTH_ATTEMPTS {
return Err(anyhow!(
"Authentication failed after {} attempts for registry {}",
auth_attempts,
image_ref.registry
));
}
let Some(auth_header) = fetched.headers.get("www-authenticate") else {
return Err(anyhow!(
"Unauthorized request but no WWW-Authenticate header found"
));
};
let auth_str = auth_header
.to_str()
.map_err(|e| anyhow!("Invalid WWW-Authenticate header encoding: {}", e))?;
// Answer the challenge even with no token configured: public
// repositories on Docker Hub and ghcr.io hand out a pull-scoped
// token to unauthenticated callers, and the tag listing is
// inaccessible without one.
bearer_token = Some(
self.fetch_registry_v2_token(auth_str, &image_ref.registry, registry_config)
.await?,
);
auth_attempts += 1;
continue;
} else if fetched.status.is_success() {
let link_next = fetched
.headers
.get("link")
.and_then(|h| h.to_str().ok())
.and_then(|h| self.parse_link_header(h));
// A page came back, so the current token works: give the next
// 401 a fresh re-auth budget. Tokens expire (Docker Hub's last
// 5 minutes) and a long scan can outlive one, which must not
// exhaust the attempt cap and abandon the listing half-read.
auth_attempts = 0;
let (tags, raw_last_tag, raw_count) = self.parse_v2_response(&fetched.body)?;
(tags, raw_last_tag, raw_count, link_next)
} else {
return Err(anyhow!(
"Registry request failed with status {}: {}",
fetched.status,
fetched.body
));
};
tags_scanned += raw_count;
results.extend(new_tags);
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;
}
// Advance via the registry's Link header when present, else the raw
// last-tag cursor. Stop on an empty page, a Link header that points
// back at the current page, or a cursor that did not move.
if let Some(next) = link_next {
let resolved = if next.starts_with("http://") || next.starts_with("https://") {
next
} else {
format!("{}{next}", registry_config.url)
};
if resolved == url {
break;
}
next_url = Some(resolved);
} else if let Some(last) = raw_last_tag {
if raw_count == 0 || last_tag.as_ref() == Some(&last) {
break;
}
last_tag = Some(last);
} else {
break;
}
}
Ok(results)
}
/// 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")?;
let auth_url = format!(
"{realm}?service={}&scope={}",
urlencoding::encode(&service),
urlencoding::encode(&scope),
);
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 {
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",
auth_url,
)
.await?;
if !token_response.status.is_success() {
return Err(anyhow!(
"Failed to get token: {} - {}",
token_response.status,
token_response.body
));
}
let token_json: serde_json::Value = serde_json::from_str(&token_response.body)
.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(registry_token.to_string());
}
Err(anyhow!("Auth response missing 'token' field"))
}
fn parse_link_header(&self, link_header: &str) -> Option<String> {
for link in link_header.split(',') {
let parts: Vec<&str> = link.trim().split(';').collect();
if parts.len() >= 2 {
let url = parts[0].trim();
if url.starts_with('<') && url.ends_with('>') {
let url = &url[1..url.len() - 1];
for param in &parts[1..] {
let param = param.trim();
if param == "rel=\"next\"" || param == "rel=next" {
return Some(url.to_string());
}
}
}
}
}
None
}
}
/// LinuxServer's gateway and GHCR serve the same image backend. Keep their
/// request budget together even when compose files use both image prefixes.
fn registry_backend(request_url: &str) -> Result<String> {
let url = reqwest::Url::parse(request_url)?;
let host = url
.host_str()
.ok_or_else(|| anyhow!("Registry URL has no host"))?;
let host = if host == "lscr.io" { "ghcr.io" } else { host };
Ok(match url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
})
}
static AUTH_PARAM_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"(\w+)="([^"]+)""#).unwrap());
/// Returns true for transient transport-level failures worth retrying:
/// connection-establishment failures (`tcp connect error: deadline has elapsed`),
/// read/overall timeouts, request-send failures such as hyper's `IncompleteMessage`
/// (`connection closed before message completed`), and body-read failures from a
/// connection dropped mid-stream. Because `fetch_with_retry` reads the body inside
/// the retry scope, `is_body` is meaningful here. Non-transient kinds (`is_decode`,
/// redirect, builder, and HTTP status errors, which arrive as a successful
/// `Response` rather than an `Err`) are not retried.
fn is_transient_transport_error(err: &reqwest::Error) -> bool {
err.is_connect() || err.is_timeout() || err.is_request() || err.is_body()
}
fn extract_auth_param(auth_str: &str, param: &str) -> Result<String> {
for caps in AUTH_PARAM_REGEX.captures_iter(auth_str) {
if caps.get(1).map(|m| m.as_str()) == Some(param) {
return Ok(caps[2].to_string());
}
}
Err(anyhow!(
"Missing '{}' in auth challenge: {}",
param,
auth_str
))
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn response(status: &str, headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}",
body.len()
)
}
async fn mock_registry(
responses: Vec<String>,
) -> (String, tokio::task::JoinHandle<Vec<String>>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let mut requests = Vec::new();
for response in responses {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut buf = [0; 1024];
while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") {
let len = stream.read(&mut buf).await.unwrap();
assert_ne!(len, 0, "connection closed before request headers");
request.extend_from_slice(&buf[..len]);
}
requests.push(String::from_utf8(request).unwrap());
stream.write_all(response.as_bytes()).await.unwrap();
}
requests
});
(url, server)
}
fn client_for_mock(url: &str, interval_ms: u64) -> Client {
let mut config = Config {
registry_request_interval_ms: interval_ms,
..Config::default()
};
config.registries.insert(
"test.registry".to_string(),
RegistryConfig {
url: url.to_string(),
auth_token: None,
username: None,
},
);
Client::new(config)
}
#[tokio::test(start_paused = true)]
async fn test_backend_pacing_shares_lscr_budget_and_isolates_other_registries() {
let client = Client::new(Config::default());
let lscr = registry_backend("https://lscr.io/v2/linuxserver/prowlarr/tags/list").unwrap();
let ghcr = registry_backend("https://ghcr.io/token?scope=repository:x:pull").unwrap();
let docker =
registry_backend("https://registry-1.docker.io/v2/library/nginx/tags/list").unwrap();
let start = Instant::now();
client.wait_for_request_slot(&lscr).await;
client.wait_for_request_slot(&docker).await;
assert_eq!(start.elapsed(), Duration::ZERO);
client.wait_for_request_slot(&ghcr).await;
assert_eq!(start.elapsed(), Duration::from_secs(1));
client.wait_for_request_slot(&lscr).await;
assert_eq!(start.elapsed(), Duration::from_secs(2));
}
#[tokio::test(start_paused = true)]
async fn test_cooldown_survives_disabled_pacing_and_cannot_be_shortened() {
let client = client_for_mock("http://localhost", 0);
let start = Instant::now();
client.defer_requests("ghcr.io", Duration::from_secs(40));
client.defer_requests("ghcr.io", Duration::from_secs(5));
client.wait_for_request_slot("docker.io").await;
assert_eq!(start.elapsed(), Duration::ZERO);
client.wait_for_request_slot("ghcr.io").await;
assert_eq!(start.elapsed(), Duration::from_secs(40));
client.wait_for_request_slot("ghcr.io").await;
assert_eq!(start.elapsed(), Duration::from_secs(40));
}
#[tokio::test]
async fn test_pacing_covers_authentication_and_tag_pages() {
let (url, server) = mock_registry(vec![
response("200 OK", "", r#"{"token":"test-token"}"#),
response(
"200 OK",
"Link: </v2/org/app/tags/list?n=1000&last=1.0.0>; rel=\"next\"\r\n",
r#"{"tags":["1.0.0"]}"#,
),
response("200 OK", "", r#"{"tags":["1.0.1"]}"#),
response("200 OK", "", r#"{"tags":[]}"#),
])
.await;
let client = client_for_mock(&url, 100);
let start = Instant::now();
client
.request_registry_v2_token(&format!("{url}/token"), None)
.await
.unwrap();
let image = ImageRef::parse("test.registry/org/app:1.0.0").unwrap();
let versions = client.get_available_versions(&image).await.unwrap();
let tags: Vec<_> = versions.iter().map(|v| v.original.as_str()).collect();
assert_eq!(tags, vec!["1.0.0", "1.0.1"]);
assert!(start.elapsed() >= Duration::from_millis(300));
let requests = server.await.unwrap();
assert_eq!(requests.len(), 4);
assert!(requests[0].starts_with("GET /token "));
assert!(requests[2].contains("last=1.0.0"));
}
#[tokio::test]
async fn test_exhausted_retries_preserve_cooldown_for_next_image() {
let mut responses = vec![
response("429 Too Many Requests", "Retry-After: 0\r\n", "{}");
MAX_RETRY_ATTEMPTS as usize
];
responses.push(response(
"429 Too Many Requests",
"Retry-After: 1\r\n",
"{}",
));
responses.push(response("200 OK", "", r#"{"tags":[]}"#));
let (url, server) = mock_registry(responses).await;
let client = client_for_mock(&url, 0);
let image = ImageRef::parse("test.registry/org/app:1.0.0").unwrap();
let error = client.get_available_versions(&image).await.unwrap_err();
let error = error.to_string();
assert!(error.contains("test.registry/org/app:1.0.0"), "{error}");
assert!(error.contains("after 5 retries"), "{error}");
assert!(error.contains("backend cooldown 1s"), "{error}");
let next_image = ImageRef::parse("test.registry/org/other:2.0.0").unwrap();
let start = Instant::now();
client.get_available_versions(&next_image).await.unwrap();
assert!(start.elapsed() >= Duration::from_millis(950));
assert_eq!(server.await.unwrap().len(), 7);
}
#[tokio::test]
async fn test_rate_limit_without_retry_after_waits_before_retrying() {
let (url, server) = mock_registry(vec![
response("429 Too Many Requests", "", "{}"),
response("200 OK", "", r#"{"tags":[]}"#),
])
.await;
let client = client_for_mock(&url, 0);
let image = ImageRef::parse("test.registry/org/app:1.0.0").unwrap();
let start = Instant::now();
client.get_available_versions(&image).await.unwrap();
assert!(start.elapsed() >= Duration::from_secs(INITIAL_RATE_LIMIT_DELAY_SECS));
assert_eq!(server.await.unwrap().len(), 2);
}
#[test]
fn test_retry_after_accepts_seconds_and_http_dates() {
let client = Client::new(Config::default());
assert_eq!(
client.parse_retry_after("45"),
Some(Duration::from_secs(45))
);
assert_eq!(client.parse_retry_after("0"), Some(Duration::ZERO));
assert_eq!(client.parse_retry_after("invalid"), None);
let date = (Utc::now() + chrono::Duration::seconds(60)).to_rfc2822();
let delay = client.parse_retry_after(&date).unwrap();
assert!(delay >= Duration::from_secs(58) && delay <= Duration::from_secs(60));
}
#[test]
fn test_image_ref_parsing() {
let test_cases = vec![
("nginx:1.21.0", "docker.io", None, "nginx", "1.21.0"),
(
"ubuntu/nginx:1.21.0",
"docker.io",
Some("ubuntu"),
"nginx",
"1.21.0",
),
(
"ghcr.io/user/app:v1.0.0",
"ghcr.io",
Some("user"),
"app",
"v1.0.0",
),
(
"localhost:5000/myapp:latest",
"localhost:5000",
None,
"myapp",
"latest",
),
(
"ghcr.io/schmelczer/fizika/fizika-admin:latest",
"ghcr.io",
Some("schmelczer/fizika"),
"fizika-admin",
"latest",
),
(
"registry.example.com/org/team/project/image:v2.0.0",
"registry.example.com",
Some("org/team/project"),
"image",
"v2.0.0",
),
];
for (input, expected_registry, expected_namespace, expected_name, expected_tag) in
test_cases
{
let image_ref = ImageRef::parse(input).unwrap();
assert_eq!(
image_ref.registry, expected_registry,
"Registry mismatch for {}",
input
);
assert_eq!(
image_ref.namespace,
expected_namespace.map(String::from),
"Namespace mismatch for {}",
input
);
assert_eq!(image_ref.name, expected_name, "Name mismatch for {}", input);
assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for {}", input);
}
}
#[test]
fn test_repository_path_building() {
let config = Config::default();
let client = Client::new(config);
// Official Docker Hub image (no namespace)
let image_ref = ImageRef {
registry: "docker.io".to_string(),
namespace: None,
name: "nginx".to_string(),
tag: "latest".to_string(),
};
assert_eq!(client.build_repository_path(&image_ref), "library/nginx");
// Docker Hub with namespace
let image_ref = ImageRef {
registry: "docker.io".to_string(),
namespace: Some("bitnami".to_string()),
name: "nginx".to_string(),
tag: "latest".to_string(),
};
assert_eq!(client.build_repository_path(&image_ref), "bitnami/nginx");
// Custom registry
let image_ref = ImageRef {
registry: "ghcr.io".to_string(),
namespace: Some("user".to_string()),
name: "app".to_string(),
tag: "v1.0.0".to_string(),
};
assert_eq!(client.build_repository_path(&image_ref), "user/app");
}
#[tokio::test]
async fn test_connect_failure_is_transient() {
// 192.0.2.1 is TEST-NET-1 (RFC 5737): guaranteed non-routable, so the
// connect attempt fails (timeout or unreachable). Either way it must be
// classified as a transient transport error so it gets retried.
let client = HttpClient::builder()
.connect_timeout(Duration::from_millis(50))
.timeout(Duration::from_millis(200))
.build()
.unwrap();
let err = client
.get("http://192.0.2.1:9/")
.send()
.await
.expect_err("connecting to a non-routable address must fail");
assert!(
is_transient_transport_error(&err),
"connect failure should be retryable, got: {err:?}"
);
}
#[test]
fn test_parse_v2_response_reports_raw_cursor_and_count() {
let client = Client::new(Config::default());
// Mixed semver and non-semver tags. The raw cursor must be the literal
// last tag (even though it is not semver) so pagination advances past it,
// and the raw count must include every tag for the scan budget.
let body =
r#"{"name":"linuxserver/radarr","tags":["6.1.0","6.1.1","latest","amd64-nightly"]}"#;
let (versions, raw_last_tag, raw_count) = client.parse_v2_response(body).unwrap();
assert_eq!(raw_count, 4);
assert_eq!(raw_last_tag.as_deref(), Some("amd64-nightly"));
let parsed: Vec<_> = versions.iter().map(|v| v.original.as_str()).collect();
assert_eq!(parsed, vec!["6.1.0", "6.1.1"]);
}
#[test]
fn test_parse_v2_response_handles_empty_and_null_tags() {
let client = Client::new(Config::default());
for body in [r#"{"name":"x","tags":[]}"#, r#"{"name":"x","tags":null}"#] {
let (versions, raw_last_tag, raw_count) = client.parse_v2_response(body).unwrap();
assert!(versions.is_empty());
assert_eq!(raw_last_tag, None);
assert_eq!(raw_count, 0, "body {body} should yield zero raw tags");
}
}
#[test]
fn test_parse_link_header() {
let config = Config::default();
let client = Client::new(config);
// Test standard Link header with next rel
let link_header = r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag99>; rel="next", <https://registry.example.com/v2/repo/tags/list?n=100&last=tag999>; rel="last""#;
let next_url = client.parse_link_header(link_header);
assert_eq!(
next_url,
Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string())
);
// Test Link header without quotes around rel value
let link_header =
r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag99>; rel=next"#;
let next_url = client.parse_link_header(link_header);
assert_eq!(
next_url,
Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string())
);
// Test Link header with no next relation
let link_header = r#"<https://registry.example.com/v2/repo/tags/list?n=100&last=tag1>; rel="prev", <https://registry.example.com/v2/repo/tags/list?n=100&last=tag999>; rel="last""#;
let next_url = client.parse_link_header(link_header);
assert_eq!(next_url, None);
// Test empty Link header
let next_url = client.parse_link_header("");
assert_eq!(next_url, None);
// Test malformed Link header
let link_header = "not-a-valid-link-header";
let next_url = client.parse_link_header(link_header);
assert_eq!(next_url, None);
}
}