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
51 changes: 51 additions & 0 deletions internal/database/metadata_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func TestUpsertAndGetMetadataCache(t *testing.T) {
Name: "lodash",
StoragePath: "_metadata/npm/lodash/metadata",
ETag: sql.NullString{String: `"abc123"`, Valid: true},
Link: sql.NullString{String: `<https://registry.example.test/next>; rel="next"`, Valid: true},
ContentType: sql.NullString{String: "application/json", Valid: true},
ContentDigest: sql.NullString{
String: "sha256:0123456789abcdef",
Expand Down Expand Up @@ -63,6 +64,9 @@ func TestUpsertAndGetMetadataCache(t *testing.T) {
if !got.ETag.Valid || got.ETag.String != `"abc123"` {
t.Errorf("etag = %v, want %q", got.ETag, `"abc123"`)
}
if !got.Link.Valid || got.Link.String != `<https://registry.example.test/next>; rel="next"` {
t.Errorf("link = %v, want next link", got.Link)
}
if !got.ContentType.Valid || got.ContentType.String != "application/json" {
t.Errorf("content_type = %v, want %q", got.ContentType, "application/json")
}
Expand Down Expand Up @@ -158,6 +162,9 @@ func TestUpsertMetadataCacheNullableFields(t *testing.T) {
if got.ContentType.Valid {
t.Error("expected null content_type")
}
if got.Link.Valid {
t.Error("expected null link")
}
if got.Size.Valid {
t.Error("expected null size")
}
Expand Down Expand Up @@ -229,3 +236,47 @@ func TestMetadataCacheContentDigestMigrationPreservesExistingRows(t *testing.T)
t.Errorf("legacy content digest = %q, want NULL", entry.ContentDigest.String)
}
}

func TestMetadataCacheLinkMigrationPreservesExistingRows(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
db, err := Create(dbPath)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
defer func() { _ = db.Close() }()

if _, err := db.Exec("ALTER TABLE metadata_cache DROP COLUMN link"); err != nil {
t.Fatalf("dropping link: %v", err)
}
if _, err := db.Exec("DELETE FROM migrations WHERE name = ?", "007_add_metadata_link"); err != nil {
t.Fatalf("resetting link migration: %v", err)
}
if _, err := db.Exec(`
INSERT INTO metadata_cache (ecosystem, name, storage_path, content_type, size, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, "oci-tags", "cache-key", "_metadata/oci-tags/cache-key/metadata", "application/json", 2, time.Now(), time.Now(), time.Now()); err != nil {
t.Fatalf("inserting legacy cache row: %v", err)
}

if err := db.MigrateSchema(); err != nil {
t.Fatalf("MigrateSchema() error = %v", err)
}
hasLink, err := db.HasColumn("metadata_cache", "link")
if err != nil {
t.Fatalf("HasColumn() error = %v", err)
}
if !hasLink {
t.Fatal("metadata_cache.link was not added")
}

entry, err := db.GetMetadataCache("oci-tags", "cache-key")
if err != nil {
t.Fatalf("GetMetadataCache() error = %v", err)
}
if entry == nil || entry.StoragePath != "_metadata/oci-tags/cache-key/metadata" {
t.Fatalf("existing metadata cache row was not preserved: %#v", entry)
}
if entry.Link.Valid {
t.Errorf("legacy link = %q, want NULL", entry.Link.String)
}
}
14 changes: 8 additions & 6 deletions internal/database/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,7 @@ func (db *DB) CountCachedPackages(ecosystem string) (int64, error) {
func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, error) {
var entry MetadataCacheEntry
query := db.Rebind(`
SELECT id, ecosystem, name, storage_path, etag, content_type,
SELECT id, ecosystem, name, storage_path, etag, link, content_type,
content_digest, size, last_modified, fetched_at, created_at, updated_at
FROM metadata_cache WHERE ecosystem = ? AND name = ?
`)
Expand All @@ -926,12 +926,13 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {

if db.dialect == DialectPostgres {
query = `
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, link, content_type,
content_digest, size, last_modified, fetched_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT(ecosystem, name) DO UPDATE SET
storage_path = EXCLUDED.storage_path,
etag = EXCLUDED.etag,
link = EXCLUDED.link,
content_type = EXCLUDED.content_type,
content_digest = EXCLUDED.content_digest,
size = EXCLUDED.size,
Expand All @@ -941,12 +942,13 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
`
} else {
query = `
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, link, content_type,
content_digest, size, last_modified, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ecosystem, name) DO UPDATE SET
storage_path = excluded.storage_path,
etag = excluded.etag,
link = excluded.link,
content_type = excluded.content_type,
content_digest = excluded.content_digest,
size = excluded.size,
Expand All @@ -957,7 +959,7 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
}

_, err := db.Exec(query,
entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag,
entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag, entry.Link,
entry.ContentType, entry.ContentDigest, entry.Size, entry.LastModified, entry.FetchedAt, now, now,
)
if err != nil {
Expand Down
27 changes: 23 additions & 4 deletions internal/database/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ CREATE TABLE IF NOT EXISTS metadata_cache (
name TEXT NOT NULL,
storage_path TEXT NOT NULL,
etag TEXT,
link TEXT,
content_type TEXT,
content_digest TEXT,
size INTEGER,
Expand Down Expand Up @@ -202,6 +203,7 @@ CREATE TABLE IF NOT EXISTS metadata_cache (
name TEXT NOT NULL,
storage_path TEXT NOT NULL,
etag TEXT,
link TEXT,
content_type TEXT,
content_digest TEXT,
size BIGINT,
Expand Down Expand Up @@ -362,6 +364,7 @@ var migrations = []migration{
{"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable},
{"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable},
{"006_add_metadata_content_digest", migrateAddMetadataContentDigest},
{"007_add_metadata_link", migrateAddMetadataLink},
}

// isTableNotFound returns true if the error indicates a missing table.
Expand Down Expand Up @@ -598,6 +601,20 @@ func migrateAddMetadataContentDigest(db *DB) error {
return nil
}

func migrateAddMetadataLink(db *DB) error {
hasColumn, err := db.HasColumn("metadata_cache", "link")
if err != nil {
return fmt.Errorf("checking metadata_cache link column: %w", err)
}
if hasColumn {
return nil
}
if _, err := db.Exec("ALTER TABLE metadata_cache ADD COLUMN link TEXT"); err != nil {
return fmt.Errorf("adding metadata_cache link column: %w", err)
}
return nil
}

// EnsureMetadataCacheTable creates the metadata_cache table if it doesn't exist.
func (db *DB) EnsureMetadataCacheTable() error {
has, err := db.HasTable("metadata_cache")
Expand All @@ -616,8 +633,9 @@ func (db *DB) EnsureMetadataCacheTable() error {
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
etag TEXT,
link TEXT,
content_type TEXT,
content_digest TEXT,
size BIGINT,
last_modified TIMESTAMP,
Expand All @@ -634,8 +652,9 @@ func (db *DB) EnsureMetadataCacheTable() error {
ecosystem TEXT NOT NULL,
name TEXT NOT NULL,
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
etag TEXT,
link TEXT,
content_type TEXT,
content_digest TEXT,
size INTEGER,
last_modified DATETIME,
Expand Down
1 change: 1 addition & 0 deletions internal/database/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ type MetadataCacheEntry struct {
Name string `db:"name" json:"name"`
StoragePath string `db:"storage_path" json:"storage_path"`
ETag sql.NullString `db:"etag" json:"etag,omitempty"`
Link sql.NullString `db:"link" json:"link,omitempty"`
ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"`
ContentDigest sql.NullString `db:"content_digest" json:"content_digest,omitempty"`
Size sql.NullInt64 `db:"size" json:"size,omitempty"`
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
Loading