From 9de623a14e3b2f2eb11662b385ff75ac98289ed2 Mon Sep 17 00:00:00 2001 From: devitway Date: Wed, 18 Mar 2026 08:07:53 +0000 Subject: [PATCH] fix: Docker dashboard for namespaced images, library/ auto-prepend for Hub official images (v0.2.32) Docker dashboard: - build_docker_index now finds manifests segment by position, not fixed index - Correctly indexes library/alpine, grafana/grafana, and other namespaced images Docker proxy: - Auto-prepend library/ for single-segment names when upstream returns 404 - Applies to both manifests and blobs - nginx, alpine, node now work without explicit library/ prefix - Cached under original name for future local hits --- Cargo.toml | 2 +- nora-registry/src/registry/docker.rs | 83 ++++++++++++++++++++++++++++ nora-registry/src/repo_index.rs | 56 ++++++++++--------- 3 files changed, 115 insertions(+), 26 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4c8c10c..4850a65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.2.31" +version = "0.2.32" edition = "2021" license = "MIT" authors = ["DevITWay "] diff --git a/nora-registry/src/registry/docker.rs b/nora-registry/src/registry/docker.rs index 0c4a4da..2eb0550 100644 --- a/nora-registry/src/registry/docker.rs +++ b/nora-registry/src/registry/docker.rs @@ -214,6 +214,38 @@ async fn download_blob( } } + // Auto-prepend library/ for single-segment names (Docker Hub official images) + if !name.contains('/') { + let library_name = format!("library/{}", name); + for upstream in &state.config.docker.upstreams { + if let Ok(data) = fetch_blob_from_upstream( + &state.http_client, + &upstream.url, + &library_name, + &digest, + &state.docker_auth, + state.config.docker.proxy_timeout, + upstream.auth.as_deref(), + ) + .await + { + let storage = state.storage.clone(); + let key_clone = key.clone(); + let data_clone = data.clone(); + tokio::spawn(async move { + let _ = storage.put(&key_clone, &data_clone).await; + }); + + return ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/octet-stream")], + Bytes::from(data), + ) + .into_response(); + } + } + } + StatusCode::NOT_FOUND.into_response() } @@ -453,6 +485,57 @@ async fn get_manifest( } } + // Auto-prepend library/ for single-segment names (Docker Hub official images) + // e.g., "nginx" -> "library/nginx", "alpine" -> "library/alpine" + if !name.contains('/') { + let library_name = format!("library/{}", name); + for upstream in &state.config.docker.upstreams { + if let Ok((data, content_type)) = fetch_manifest_from_upstream( + &state.http_client, + &upstream.url, + &library_name, + &reference, + &state.docker_auth, + state.config.docker.proxy_timeout, + upstream.auth.as_deref(), + ) + .await + { + state.metrics.record_download("docker"); + state.metrics.record_cache_miss(); + state.activity.push(ActivityEntry::new( + ActionType::ProxyFetch, + format!("{}:{}", name, reference), + "docker", + "PROXY", + )); + + use sha2::Digest; + let digest = format!("sha256:{:x}", sha2::Sha256::digest(&data)); + + // Cache under original name for future local hits + let storage = state.storage.clone(); + let key_clone = key.clone(); + let data_clone = data.clone(); + tokio::spawn(async move { + let _ = storage.put(&key_clone, &data_clone).await; + }); + + state.repo_index.invalidate("docker"); + + return ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, content_type), + (HeaderName::from_static("docker-content-digest"), digest), + ], + Bytes::from(data), + ) + .into_response(); + } + } + } + StatusCode::NOT_FOUND.into_response() } diff --git a/nora-registry/src/repo_index.rs b/nora-registry/src/repo_index.rs index a3e7204..993429b 100644 --- a/nora-registry/src/repo_index.rs +++ b/nora-registry/src/repo_index.rs @@ -173,35 +173,41 @@ async fn build_docker_index(storage: &Storage) -> Vec { } if let Some(rest) = key.strip_prefix("docker/") { + // Support both single-segment and namespaced images: + // docker/alpine/manifests/latest.json → name="alpine" + // docker/library/alpine/manifests/latest.json → name="library/alpine" let parts: Vec<_> = rest.split('/').collect(); - if parts.len() >= 3 && parts[1] == "manifests" && key.ends_with(".json") { - let name = parts[0].to_string(); - let entry = repos.entry(name).or_insert((0, 0, 0)); - entry.0 += 1; + let manifest_pos = parts.iter().position(|&p| p == "manifests"); + if let Some(pos) = manifest_pos { + if pos >= 1 && key.ends_with(".json") { + let name = parts[..pos].join("/"); + let entry = repos.entry(name).or_insert((0, 0, 0)); + entry.0 += 1; - if let Ok(data) = storage.get(key).await { - if let Ok(m) = serde_json::from_slice::(&data) { - let cfg = m - .get("config") - .and_then(|c| c.get("size")) - .and_then(|s| s.as_u64()) - .unwrap_or(0); - let layers: u64 = m - .get("layers") - .and_then(|l| l.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|l| l.get("size").and_then(|s| s.as_u64())) - .sum() - }) - .unwrap_or(0); - entry.1 += cfg + layers; + if let Ok(data) = storage.get(key).await { + if let Ok(m) = serde_json::from_slice::(&data) { + let cfg = m + .get("config") + .and_then(|c| c.get("size")) + .and_then(|s| s.as_u64()) + .unwrap_or(0); + let layers: u64 = m + .get("layers") + .and_then(|l| l.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|l| l.get("size").and_then(|s| s.as_u64())) + .sum() + }) + .unwrap_or(0); + entry.1 += cfg + layers; + } } - } - if let Some(meta) = storage.stat(key).await { - if meta.modified > entry.2 { - entry.2 = meta.modified; + if let Some(meta) = storage.stat(key).await { + if meta.modified > entry.2 { + entry.2 = meta.modified; + } } } }