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
This commit is contained in:
2026-03-18 08:07:53 +00:00
parent aa86633a04
commit e415f0f1ce
3 changed files with 115 additions and 26 deletions

View File

@@ -8,7 +8,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.2.31" version = "0.2.32"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["DevITWay <devitway@gmail.com>"] authors = ["DevITWay <devitway@gmail.com>"]

View File

@@ -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() 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() StatusCode::NOT_FOUND.into_response()
} }

View File

@@ -173,35 +173,41 @@ async fn build_docker_index(storage: &Storage) -> Vec<RepoInfo> {
} }
if let Some(rest) = key.strip_prefix("docker/") { 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(); let parts: Vec<_> = rest.split('/').collect();
if parts.len() >= 3 && parts[1] == "manifests" && key.ends_with(".json") { let manifest_pos = parts.iter().position(|&p| p == "manifests");
let name = parts[0].to_string(); if let Some(pos) = manifest_pos {
let entry = repos.entry(name).or_insert((0, 0, 0)); if pos >= 1 && key.ends_with(".json") {
entry.0 += 1; 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(data) = storage.get(key).await {
if let Ok(m) = serde_json::from_slice::<serde_json::Value>(&data) { if let Ok(m) = serde_json::from_slice::<serde_json::Value>(&data) {
let cfg = m let cfg = m
.get("config") .get("config")
.and_then(|c| c.get("size")) .and_then(|c| c.get("size"))
.and_then(|s| s.as_u64()) .and_then(|s| s.as_u64())
.unwrap_or(0); .unwrap_or(0);
let layers: u64 = m let layers: u64 = m
.get("layers") .get("layers")
.and_then(|l| l.as_array()) .and_then(|l| l.as_array())
.map(|arr| { .map(|arr| {
arr.iter() arr.iter()
.filter_map(|l| l.get("size").and_then(|s| s.as_u64())) .filter_map(|l| l.get("size").and_then(|s| s.as_u64()))
.sum() .sum()
}) })
.unwrap_or(0); .unwrap_or(0);
entry.1 += cfg + layers; entry.1 += cfg + layers;
}
} }
}
if let Some(meta) = storage.stat(key).await { if let Some(meta) = storage.stat(key).await {
if meta.modified > entry.2 { if meta.modified > entry.2 {
entry.2 = meta.modified; entry.2 = meta.modified;
}
} }
} }
} }