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
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +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

**Why stream artifacts?**
- Memory efficient - don't load large files into RAM
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ 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.

```yaml
cache_metadata: true
```
Expand Down
55 changes: 53 additions & 2 deletions internal/database/metadata_cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ func TestUpsertAndGetMetadataCache(t *testing.T) {
StoragePath: "_metadata/npm/lodash/metadata",
ETag: sql.NullString{String: `"abc123"`, Valid: true},
ContentType: sql.NullString{String: "application/json", Valid: true},
Size: sql.NullInt64{Int64: 1024, Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
ContentDigest: sql.NullString{
String: "sha256:0123456789abcdef",
Valid: true,
},
Size: sql.NullInt64{Int64: 1024, Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
}

err := db.UpsertMetadataCache(entry)
Expand Down Expand Up @@ -62,6 +66,9 @@ func TestUpsertAndGetMetadataCache(t *testing.T) {
if !got.ContentType.Valid || got.ContentType.String != "application/json" {
t.Errorf("content_type = %v, want %q", got.ContentType, "application/json")
}
if !got.ContentDigest.Valid || got.ContentDigest.String != "sha256:0123456789abcdef" {
t.Errorf("content_digest = %v, want %q", got.ContentDigest, "sha256:0123456789abcdef")
}
if !got.Size.Valid || got.Size.Int64 != 1024 {
t.Errorf("size = %v, want 1024", got.Size)
}
Expand Down Expand Up @@ -178,3 +185,47 @@ func TestMetadataCacheTableCreatedByMigration(t *testing.T) {
t.Error("metadata_cache table should exist after migration")
}
}

func TestMetadataCacheContentDigestMigrationPreservesExistingRows(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 content_digest"); err != nil {
t.Fatalf("dropping content_digest: %v", err)
}
if _, err := db.Exec("DELETE FROM migrations WHERE name = ?", "006_add_metadata_content_digest"); err != nil {
t.Fatalf("resetting digest 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-manifest", "cache-key", "_metadata/oci-manifest/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)
}
hasDigest, err := db.HasColumn("metadata_cache", "content_digest")
if err != nil {
t.Fatalf("HasColumn() error = %v", err)
}
if !hasDigest {
t.Fatal("metadata_cache.content_digest was not added")
}

entry, err := db.GetMetadataCache("oci-manifest", "cache-key")
if err != nil {
t.Fatalf("GetMetadataCache() error = %v", err)
}
if entry == nil || entry.StoragePath != "_metadata/oci-manifest/cache-key/metadata" {
t.Fatalf("existing metadata cache row was not preserved: %#v", entry)
}
if entry.ContentDigest.Valid {
t.Errorf("legacy content digest = %q, want NULL", entry.ContentDigest.String)
}
}
14 changes: 8 additions & 6 deletions internal/database/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ func (db *DB) GetMetadataCache(ecosystem, name string) (*MetadataCacheEntry, err
var entry MetadataCacheEntry
query := db.Rebind(`
SELECT id, ecosystem, name, storage_path, etag, content_type,
size, last_modified, fetched_at, created_at, updated_at
content_digest, size, last_modified, fetched_at, created_at, updated_at
FROM metadata_cache WHERE ecosystem = ? AND name = ?
`)
err := db.Get(&entry, query, ecosystem, name)
Expand All @@ -914,12 +914,13 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
if db.dialect == DialectPostgres {
query = `
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
size, last_modified, fetched_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
content_digest, size, last_modified, fetched_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT(ecosystem, name) DO UPDATE SET
storage_path = EXCLUDED.storage_path,
etag = EXCLUDED.etag,
content_type = EXCLUDED.content_type,
content_digest = EXCLUDED.content_digest,
size = EXCLUDED.size,
last_modified = EXCLUDED.last_modified,
fetched_at = EXCLUDED.fetched_at,
Expand All @@ -928,12 +929,13 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
} else {
query = `
INSERT INTO metadata_cache (ecosystem, name, storage_path, etag, content_type,
size, last_modified, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
content_digest, size, last_modified, fetched_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(ecosystem, name) DO UPDATE SET
storage_path = excluded.storage_path,
etag = excluded.etag,
content_type = excluded.content_type,
content_digest = excluded.content_digest,
size = excluded.size,
last_modified = excluded.last_modified,
fetched_at = excluded.fetched_at,
Expand All @@ -943,7 +945,7 @@ func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {

_, err := db.Exec(query,
entry.Ecosystem, entry.Name, entry.StoragePath, entry.ETag,
entry.ContentType, entry.Size, entry.LastModified, entry.FetchedAt, now, now,
entry.ContentType, entry.ContentDigest, entry.Size, entry.LastModified, entry.FetchedAt, now, now,
)
if err != nil {
return fmt.Errorf("upserting metadata cache: %w", err)
Expand Down
19 changes: 19 additions & 0 deletions internal/database/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ CREATE TABLE IF NOT EXISTS metadata_cache (
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
content_digest TEXT,
size INTEGER,
last_modified DATETIME,
fetched_at DATETIME,
Expand Down Expand Up @@ -202,6 +203,7 @@ CREATE TABLE IF NOT EXISTS metadata_cache (
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
content_digest TEXT,
size BIGINT,
last_modified TIMESTAMP,
fetched_at TIMESTAMP,
Expand Down Expand Up @@ -359,6 +361,7 @@ var migrations = []migration{
{"003_ensure_artifacts_table", migrateEnsureArtifactsTable},
{"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable},
{"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable},
{"006_add_metadata_content_digest", migrateAddMetadataContentDigest},
}

// isTableNotFound returns true if the error indicates a missing table.
Expand Down Expand Up @@ -581,6 +584,20 @@ func migrateEnsureMetadataCacheTable(db *DB) error {
return db.EnsureMetadataCacheTable()
}

func migrateAddMetadataContentDigest(db *DB) error {
hasColumn, err := db.HasColumn("metadata_cache", "content_digest")
if err != nil {
return fmt.Errorf("checking metadata_cache content_digest column: %w", err)
}
if hasColumn {
return nil
}
if _, err := db.Exec("ALTER TABLE metadata_cache ADD COLUMN content_digest TEXT"); err != nil {
return fmt.Errorf("adding metadata_cache content_digest 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 @@ -601,6 +618,7 @@ func (db *DB) EnsureMetadataCacheTable() error {
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
content_digest TEXT,
size BIGINT,
last_modified TIMESTAMP,
fetched_at TIMESTAMP,
Expand All @@ -618,6 +636,7 @@ func (db *DB) EnsureMetadataCacheTable() error {
storage_path TEXT NOT NULL,
etag TEXT,
content_type TEXT,
content_digest TEXT,
size INTEGER,
last_modified DATETIME,
fetched_at DATETIME,
Expand Down
23 changes: 12 additions & 11 deletions internal/database/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,18 @@ func (a *Artifact) IsCached() bool {

// MetadataCacheEntry represents a cached metadata blob for offline serving.
type MetadataCacheEntry struct {
ID int64 `db:"id" json:"id"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Name string `db:"name" json:"name"`
StoragePath string `db:"storage_path" json:"storage_path"`
ETag sql.NullString `db:"etag" json:"etag,omitempty"`
ContentType sql.NullString `db:"content_type" json:"content_type,omitempty"`
Size sql.NullInt64 `db:"size" json:"size,omitempty"`
LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"`
FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID int64 `db:"id" json:"id"`
Ecosystem string `db:"ecosystem" json:"ecosystem"`
Name string `db:"name" json:"name"`
StoragePath string `db:"storage_path" json:"storage_path"`
ETag sql.NullString `db:"etag" json:"etag,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"`
LastModified sql.NullTime `db:"last_modified" json:"last_modified,omitempty"`
FetchedAt sql.NullTime `db:"fetched_at" json:"fetched_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

// Vulnerability represents a cached vulnerability record.
Expand Down
Loading