Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ Eviction can be implemented as:
- Fresh data - new versions visible immediately
- Metadata is small, upstream fetch is fast
- Set `cache_metadata: true` or use the mirror command to enable metadata caching for offline use via the `metadata_cache` table
- OCI manifests are the exception: they are cached automatically so previously fetched images remain pullable when the registry or token service is unavailable
- OCI manifests and tag lists are exceptions: they are cached automatically so previously fetched images remain pullable and tag resolution works when the registry or token service is unavailable

**Why stream artifacts?**
- Memory efficient - don't load large files into RAM
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ Note: Hex cooldown requires disabling registry signature verification since the

By default the proxy fetches metadata fresh from upstream on every request. Enable `cache_metadata` to store metadata responses in the database and storage backend for offline fallback. When upstream is unreachable, the proxy serves the last cached copy. ETag-based revalidation avoids re-downloading unchanged metadata.

OCI manifests are always cached because cached image blobs cannot be pulled without their manifests. Digest-addressed manifests are immutable and served directly from cache. Tag-addressed manifests follow `metadata_ttl`, revalidate when stale, and fall back to the last cached response when the registry is unavailable.
OCI manifests and tag lists are always cached because cached image blobs cannot be pulled without their manifests and offline clients may need tag resolution. Digest-addressed manifests are immutable and served directly from cache. Tag-addressed manifests and tag lists follow `metadata_ttl`, revalidate when stale, and fall back to the last cached response when the registry is unavailable.

```yaml
cache_metadata: true
Expand Down
25 changes: 2 additions & 23 deletions internal/handler/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
Expand Down Expand Up @@ -182,7 +181,7 @@ func (h *ContainerHandler) handleManifest(w http.ResponseWriter, r *http.Request
h.serveManifest(w, r, registryURL, upstreamName, reference)
}

// handleTagsList proxies tag list requests to upstream.
// handleTagsList caches tag list responses for offline OCI pulls.
func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request, path string) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
Expand All @@ -201,27 +200,7 @@ func (h *ContainerHandler) handleTagsList(w http.ResponseWriter, r *http.Request
return
}

upstreamURL := fmt.Sprintf("%s/v2/%s/tags/list", registryURL, upstreamName)
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
}

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
h.containerError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to create request")
return
}

resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch from upstream")
return
}
defer func() { _ = resp.Body.Close() }()

w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
h.serveTagsList(w, r, registryURL, upstreamName)
}

// proxyBlobHead handles HEAD requests for blobs.
Expand Down
172 changes: 156 additions & 16 deletions internal/handler/container_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"encoding/hex"
"fmt"
"io"
"mime"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
Expand All @@ -20,6 +22,10 @@ import (
const (
containerManifestCacheEcosystem = "oci-manifest"
containerStaleWarning = `110 - "Response is Stale"`

containerAcceptWildcardSpecificity = iota
containerAcceptTypeWildcardSpecificity
containerAcceptExactSpecificity
)

var manifestDigestReferencePattern = regexp.MustCompile(`^[a-z0-9]+:[a-f0-9]+$`)
Expand All @@ -35,12 +41,9 @@ type cachedContainerManifest struct {

func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, registryURL, name, reference string) {
accept := containerManifestAccept(r)
cacheKey := h.containerManifestCacheKey(registryURL, name, reference, accept)
cached, err := h.loadContainerManifest(r.Context(), cacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read cached container manifest", "error", err)
cached = nil
}
cacheAccept := normalizeContainerManifestAccept(accept)
cacheKey := h.containerManifestCacheKey(registryURL, name, reference, cacheAccept)
cached := h.loadContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheKey)

immutable := manifestDigestReferencePattern.MatchString(reference)
if cached != nil && (immutable || h.containerManifestFresh(cached)) {
Expand Down Expand Up @@ -68,9 +71,7 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,

if resp.StatusCode == http.StatusNotModified && cached != nil {
cached.fetchedAt = time.Now()
if err := h.storeContainerManifest(r.Context(), cacheKey, cached); err != nil {
h.proxy.Logger.Warn("failed to refresh cached container manifest", "error", err)
}
h.storeContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheAccept, cached)
writeContainerManifest(w, r.Method, cached, false)
return
}
Expand Down Expand Up @@ -107,14 +108,9 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,
if manifest.contentDigest == "" {
manifest.contentDigest = sha256Digest(body)
}
if err := h.storeContainerManifest(r.Context(), cacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest", "error", err)
}
h.storeContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheAccept, manifest)
if manifest.contentDigest != reference && manifestDigestReferencePattern.MatchString(manifest.contentDigest) {
digestKey := h.containerManifestCacheKey(registryURL, name, manifest.contentDigest, accept)
if err := h.storeContainerManifest(r.Context(), digestKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest by digest", "error", err)
}
h.storeContainerManifestForAccept(r.Context(), registryURL, name, manifest.contentDigest, accept, cacheAccept, manifest)
}
writeContainerManifest(w, r.Method, manifest, false)
}
Expand All @@ -139,6 +135,52 @@ func (h *ContainerHandler) containerManifestCacheKey(registryURL, name, referenc
return hex.EncodeToString(sum[:])
}

func (h *ContainerHandler) loadContainerManifestForAccept(ctx context.Context, registryURL, name, reference, accept, cacheKey string) *cachedContainerManifest {
cached, err := h.loadContainerManifest(ctx, cacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read cached container manifest", "error", err)
return nil
}
if cached != nil {
if containerManifestCacheCompatible(accept, cached) {
return cached
}
return nil
}

legacyCacheKey := h.containerManifestCacheKey(registryURL, name, reference, accept)
if legacyCacheKey == cacheKey {
return nil
}
cached, err = h.loadContainerManifest(ctx, legacyCacheKey)
if err != nil {
h.proxy.Logger.Warn("failed to read legacy cached container manifest", "error", err)
return nil
}
if cached == nil || !containerManifestCacheCompatible(accept, cached) {
return nil
}
if err := h.storeContainerManifest(ctx, cacheKey, cached); err != nil {
h.proxy.Logger.Warn("failed to migrate cached container manifest", "error", err)
}
return cached
}

func (h *ContainerHandler) storeContainerManifestForAccept(ctx context.Context, registryURL, name, reference, accept, cacheAccept string, manifest *cachedContainerManifest) {
cacheKey := h.containerManifestCacheKey(registryURL, name, reference, cacheAccept)
if err := h.storeContainerManifest(ctx, cacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest", "error", err)
}

legacyCacheKey := h.containerManifestCacheKey(registryURL, name, reference, accept)
if legacyCacheKey == cacheKey {
return
}
if err := h.storeContainerManifest(ctx, legacyCacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache legacy container manifest", "error", err)
}
}

func (h *ContainerHandler) loadContainerManifest(ctx context.Context, cacheKey string) (*cachedContainerManifest, error) {
if h.proxy.DB == nil || h.proxy.Storage == nil {
return nil, nil
Expand Down Expand Up @@ -233,6 +275,104 @@ func containerManifestAccept(r *http.Request) string {
}, ", ")
}

func normalizeContainerManifestAccept(accept string) string {
mediaTypes := make(map[string]struct{})
for _, value := range strings.Split(accept, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
mediaType, params, err := mime.ParseMediaType(value)
if err != nil {
mediaTypes[strings.ToLower(value)] = struct{}{}
continue
}
paramKeys := make([]string, 0, len(params))
for key := range params {
paramKeys = append(paramKeys, key)
}
sort.Strings(paramKeys)
canonical := strings.ToLower(mediaType)
for _, key := range paramKeys {
value := params[key]
if strings.EqualFold(key, "q") {
if quality, err := strconv.ParseFloat(value, 64); err == nil {
if quality == 1 {
continue
}
value = strconv.FormatFloat(quality, 'g', -1, 64)
}
}
canonical += ";" + strings.ToLower(key) + "=" + value
}
mediaTypes[canonical] = struct{}{}
}
canonicalMediaTypes := make([]string, 0, len(mediaTypes))
for mediaType := range mediaTypes {
canonicalMediaTypes = append(canonicalMediaTypes, mediaType)
}
sort.Strings(canonicalMediaTypes)
return strings.Join(canonicalMediaTypes, ",")
}

func containerManifestAccepts(accept, contentType string) bool {
contentType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return false
}
contentType = strings.ToLower(contentType)
contentMajor, contentMinor, found := strings.Cut(contentType, "/")
if !found {
return false
}

bestSpecificity := -1
bestQuality := 0.0
for _, value := range strings.Split(accept, ",") {
mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil {
continue
}
mediaType = strings.ToLower(mediaType)
major, minor, found := strings.Cut(mediaType, "/")
if found && (major == "*" || major == contentMajor) && (minor == "*" || minor == contentMinor) {
specificity := containerAcceptSpecificity(major, minor)
if specificity > bestSpecificity {
bestSpecificity = specificity
bestQuality = containerAcceptQuality(params)
}
}
}
return bestQuality > 0
}

func containerManifestCacheCompatible(accept string, manifest *cachedContainerManifest) bool {
return manifest.contentType == "" || containerManifestAccepts(accept, manifest.contentType)
}

func containerAcceptSpecificity(major, minor string) int {
switch {
case major == "*" && minor == "*":
return containerAcceptWildcardSpecificity
case major == "*" || minor == "*":
return containerAcceptTypeWildcardSpecificity
default:
return containerAcceptExactSpecificity
}
}

func containerAcceptQuality(params map[string]string) float64 {
value, ok := params["q"]
if !ok {
return 1
}
quality, err := strconv.ParseFloat(value, 64)
if err != nil || quality < 0 || quality > 1 {
return 0
}
return quality
}

func copyContainerManifestHeaders(destination, source http.Header) {
for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "WWW-Authenticate"} {
if value := source.Get(header); value != "" {
Expand Down
Loading
Loading