2026-01-20 21:52:44 +00:00
|
|
|
package database
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
2026-01-20 21:52:44 +00:00
|
|
|
|
2026-05-05 10:25:17 +01:00
|
|
|
const (
|
|
|
|
|
postgresTimestamp = "TIMESTAMP"
|
|
|
|
|
sqliteDatetime = "DATETIME"
|
|
|
|
|
colTypeText = "TEXT"
|
|
|
|
|
)
|
2026-03-18 10:59:29 +00:00
|
|
|
|
2026-01-29 16:44:01 +00:00
|
|
|
// Schema for proxy-specific tables. The packages and versions tables
|
|
|
|
|
// are compatible with git-pkgs, allowing the proxy to use an existing
|
|
|
|
|
// git-pkgs database as a starting point.
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
var schemaSQLite = `
|
|
|
|
|
CREATE TABLE IF NOT EXISTS schema_info (
|
|
|
|
|
version INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS packages (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
purl TEXT NOT NULL,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
latest_version TEXT,
|
|
|
|
|
license TEXT,
|
|
|
|
|
description TEXT,
|
|
|
|
|
homepage TEXT,
|
|
|
|
|
repository_url TEXT,
|
2026-01-29 16:44:01 +00:00
|
|
|
registry_url TEXT,
|
|
|
|
|
supplier_name TEXT,
|
|
|
|
|
supplier_type TEXT,
|
|
|
|
|
source TEXT,
|
|
|
|
|
enriched_at DATETIME,
|
|
|
|
|
vulns_synced_at DATETIME,
|
2026-01-29 16:06:56 +00:00
|
|
|
created_at DATETIME,
|
|
|
|
|
updated_at DATETIME
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_packages_purl ON packages(purl);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_packages_ecosystem_name ON packages(ecosystem, name);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS versions (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
purl TEXT NOT NULL,
|
2026-01-29 16:44:01 +00:00
|
|
|
package_purl TEXT NOT NULL,
|
2026-01-29 16:06:56 +00:00
|
|
|
license TEXT,
|
|
|
|
|
published_at DATETIME,
|
2026-01-29 16:44:01 +00:00
|
|
|
integrity TEXT,
|
2026-01-29 16:06:56 +00:00
|
|
|
yanked INTEGER DEFAULT 0,
|
2026-01-29 16:44:01 +00:00
|
|
|
source TEXT,
|
|
|
|
|
enriched_at DATETIME,
|
2026-01-29 16:06:56 +00:00
|
|
|
created_at DATETIME,
|
|
|
|
|
updated_at DATETIME
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_versions_purl ON versions(purl);
|
2026-01-29 16:44:01 +00:00
|
|
|
CREATE INDEX IF NOT EXISTS idx_versions_package_purl ON versions(package_purl);
|
2026-01-29 16:06:56 +00:00
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS artifacts (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
2026-01-29 16:44:01 +00:00
|
|
|
version_purl TEXT NOT NULL,
|
2026-01-29 16:06:56 +00:00
|
|
|
filename TEXT NOT NULL,
|
|
|
|
|
upstream_url TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT,
|
|
|
|
|
content_hash TEXT,
|
|
|
|
|
size INTEGER,
|
|
|
|
|
content_type TEXT,
|
|
|
|
|
fetched_at DATETIME,
|
|
|
|
|
hit_count INTEGER DEFAULT 0,
|
|
|
|
|
last_accessed_at DATETIME,
|
|
|
|
|
created_at DATETIME,
|
2026-10-13 12:35:22 +02:00
|
|
|
updated_at DATETIME,
|
2026-10-13 13:19:58 +02:00
|
|
|
source TEXT NOT NULL DEFAULT 'upstream',
|
|
|
|
|
manifest TEXT
|
2026-01-29 16:06:56 +00:00
|
|
|
);
|
2026-01-29 16:44:01 +00:00
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_version_filename ON artifacts(version_purl, filename);
|
2026-01-29 16:06:56 +00:00
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_storage_path ON artifacts(storage_path);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_last_accessed ON artifacts(last_accessed_at);
|
2026-01-29 19:35:15 +00:00
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
CREATE TABLE IF NOT EXISTS pending_deletes (
|
|
|
|
|
path TEXT NOT NULL PRIMARY KEY,
|
|
|
|
|
queued_at DATETIME NOT NULL
|
|
|
|
|
);
|
Add age-based retention for cached artifacts (#428)
* Add age-based retention for cached artifacts
Cached artifacts stayed forever unless storage.max_size pushed them out.
storage.retention adds a default, per-ecosystem and per-package duration
after which an artifact nobody downloaded gets evicted.
Ecosystems opt in one at a time: a handler registers with
retention.Default, and until it does, configuring that ecosystem (or one
of its packages) fails validation, and the default doesn't touch it. This
commit registers none, so nothing changes yet. Startup logs which
ecosystems the default covers and which it doesn't.
An artifact counts as expired when both its fetch time and its last
access are older than the cutoff. Checking only the last access would
evict a refetched artifact again right away, since clearing a record
keeps its old access time and a cache miss doesn't record a hit.
The sweep scans artifacts in id windows, so no single query holds the
SQLite connection for long, and checks each row against its own rule.
Expired records are cleared and their files go through pending_deletes
instead of being deleted directly, because a buffered hit can make an
artifact that's being served right now look expired. Buffered hits are
flushed before each sweep, and one sweep clears at most 10,000
artifacts.
Since a big first sweep can queue a lot, reclaim now keeps working
through due entries for up to 30 seconds per tick instead of stopping
after 100, and pending_deletes gets an index on queued_at.
Also:
- proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and
retention evictions.
- gradle.build_cache.max_age accepts "7d", as its comment always said.
Refs #306
* Document storage retention
Describes the new storage.retention block, the order rules apply in,
that the default only covers ecosystems that support retention yet, and
what to expect from the first sweep over an old cache. Also lists the
new eviction counter and notes that gradle max_age takes days.
Refs #306
* Fix retention timing on Postgres and tighten the sweep
On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns
without a zone. They hold the local time the proxy wrote, but lib/pq
reads them back labelled UTC. East of UTC that makes every artifact look
hours younger than it is, so the sweep evicted late: two hours in
Berlin, nine in Tokyo. The candidates' times are now read back as local
time, and the database tests run on both SQLite and Postgres, with one
that sets the local zone to Asia/Tokyo.
A few smaller things:
- When a delete fails, reclaim stops for the rest of the minute instead
of trying every queued path. Otherwise a storage outage turns into
thirty seconds of failed deletes and warnings every minute.
- If the buffered hits can't be written, the sweep skips that round.
Running on stale access times could clear something that is being
downloaded right now.
- DefaultCanonical only accepts a package key when rebuilding it gives
the same PURL. For alpine and deb it would otherwise produce keys
that never match.
- sweep_interval has to be at least a minute.
- New tests cover the eviction counter and an artifact that gets a hit
between the scan and the clear.
- The docs no longer say nothing changes without retention, and they
point out that the example's ecosystem and package rules only pass
validation once those ecosystems support retention.
Refs #306
* Reject qualifiers and subpaths in retention package keys
A rule names a whole package. Qualifiers like ?type=pom were dropped on
the way to the stored PURL and the rule quietly covered every file of the
package. Now such a key fails validation.
Refs #306
* Say when a package key doesn't match how packages are stored
A package rule for an ecosystem that supports retention, written in a
form its packages aren't stored under (pkg:npm/babel/core without the
@), failed with "retention is not supported for npm packages". Once the
ecosystem is enabled that is no longer true. Such a key now fails with
"does not match how npm packages are stored" and points to the package
rule forms in the docs. Keys for ecosystems without retention support
keep the old message.
Refs #306
* Note that re-running the mirror keeps packages cached
A mirror run over a package that is still cached goes through the normal
cache lookup and records a download, so the package's age starts over.
The docs only said that mirrored packages age from the time they were
mirrored.
Refs #306
* Say when an ecosystem takes no package rules
Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule.
Refs #306
* Use helm as the example of an ecosystem without package rules
Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart.
Refs #306
* Say that each cached file ages on its own
The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately.
Refs #306
* Clear an expired artifact and queue its file in one transaction
The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries.
Refs #306
* Show artifact evictions on the analytics page
proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order.
Refs #306
* Store artifact times in UTC
fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read.
Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early.
Refs #306
---------
Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
|
|
|
CREATE INDEX IF NOT EXISTS idx_pending_deletes_queued_at ON pending_deletes(queued_at);
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
|
2026-01-29 19:35:15 +00:00
|
|
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
vuln_id TEXT NOT NULL,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
package_name TEXT NOT NULL,
|
|
|
|
|
severity TEXT,
|
|
|
|
|
summary TEXT,
|
|
|
|
|
fixed_version TEXT,
|
|
|
|
|
cvss_score REAL,
|
|
|
|
|
"references" TEXT,
|
|
|
|
|
fetched_at DATETIME,
|
|
|
|
|
created_at DATETIME,
|
|
|
|
|
updated_at DATETIME
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
|
2026-04-06 13:06:45 +01:00
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
CREATE TABLE IF NOT EXISTS metadata_cache (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT NOT NULL,
|
|
|
|
|
etag TEXT,
|
2026-09-02 17:10:35 +05:30
|
|
|
link TEXT,
|
2026-03-19 21:06:02 +00:00
|
|
|
content_type TEXT,
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
content_encoding TEXT,
|
2026-08-13 07:35:07 +01:00
|
|
|
content_digest TEXT,
|
2026-03-19 21:06:02 +00:00
|
|
|
size INTEGER,
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
last_modified DATETIME,
|
2026-03-19 21:06:02 +00:00
|
|
|
fetched_at DATETIME,
|
|
|
|
|
created_at DATETIME,
|
|
|
|
|
updated_at DATETIME
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
|
|
|
|
|
|
2026-10-12 12:52:02 +02:00
|
|
|
CREATE TABLE IF NOT EXISTS metadata_dates (
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
proxy_url TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
etag TEXT NOT NULL,
|
|
|
|
|
last_modified INTEGER NOT NULL,
|
|
|
|
|
PRIMARY KEY (ecosystem, proxy_url, name)
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
CREATE TABLE IF NOT EXISTS migrations (
|
|
|
|
|
name TEXT NOT NULL PRIMARY KEY,
|
|
|
|
|
applied_at DATETIME NOT NULL
|
|
|
|
|
);
|
2026-01-29 16:06:56 +00:00
|
|
|
`
|
|
|
|
|
|
|
|
|
|
var schemaPostgres = `
|
|
|
|
|
CREATE TABLE IF NOT EXISTS schema_info (
|
|
|
|
|
version INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS packages (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
purl TEXT NOT NULL,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
latest_version TEXT,
|
|
|
|
|
license TEXT,
|
|
|
|
|
description TEXT,
|
|
|
|
|
homepage TEXT,
|
|
|
|
|
repository_url TEXT,
|
2026-01-29 16:44:01 +00:00
|
|
|
registry_url TEXT,
|
|
|
|
|
supplier_name TEXT,
|
|
|
|
|
supplier_type TEXT,
|
|
|
|
|
source TEXT,
|
|
|
|
|
enriched_at TIMESTAMP,
|
|
|
|
|
vulns_synced_at TIMESTAMP,
|
2026-01-29 16:06:56 +00:00
|
|
|
created_at TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_packages_purl ON packages(purl);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_packages_ecosystem_name ON packages(ecosystem, name);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS versions (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
purl TEXT NOT NULL,
|
2026-01-29 16:44:01 +00:00
|
|
|
package_purl TEXT NOT NULL,
|
2026-01-29 16:06:56 +00:00
|
|
|
license TEXT,
|
|
|
|
|
published_at TIMESTAMP,
|
2026-01-29 16:44:01 +00:00
|
|
|
integrity TEXT,
|
2026-01-29 16:06:56 +00:00
|
|
|
yanked BOOLEAN DEFAULT FALSE,
|
2026-01-29 16:44:01 +00:00
|
|
|
source TEXT,
|
|
|
|
|
enriched_at TIMESTAMP,
|
2026-01-29 16:06:56 +00:00
|
|
|
created_at TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_versions_purl ON versions(purl);
|
2026-01-29 16:44:01 +00:00
|
|
|
CREATE INDEX IF NOT EXISTS idx_versions_package_purl ON versions(package_purl);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS artifacts (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
version_purl TEXT NOT NULL,
|
|
|
|
|
filename TEXT NOT NULL,
|
|
|
|
|
upstream_url TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT,
|
|
|
|
|
content_hash TEXT,
|
|
|
|
|
size BIGINT,
|
|
|
|
|
content_type TEXT,
|
|
|
|
|
fetched_at TIMESTAMP,
|
|
|
|
|
hit_count BIGINT DEFAULT 0,
|
|
|
|
|
last_accessed_at TIMESTAMP,
|
|
|
|
|
created_at TIMESTAMP,
|
2026-10-13 12:35:22 +02:00
|
|
|
updated_at TIMESTAMP,
|
2026-10-13 13:19:58 +02:00
|
|
|
source TEXT NOT NULL DEFAULT 'upstream',
|
|
|
|
|
manifest TEXT
|
2026-01-29 16:44:01 +00:00
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_version_filename ON artifacts(version_purl, filename);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_storage_path ON artifacts(storage_path);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_last_accessed ON artifacts(last_accessed_at);
|
2026-01-29 19:35:15 +00:00
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
CREATE TABLE IF NOT EXISTS pending_deletes (
|
|
|
|
|
path TEXT NOT NULL PRIMARY KEY,
|
|
|
|
|
queued_at TIMESTAMP NOT NULL
|
|
|
|
|
);
|
Add age-based retention for cached artifacts (#428)
* Add age-based retention for cached artifacts
Cached artifacts stayed forever unless storage.max_size pushed them out.
storage.retention adds a default, per-ecosystem and per-package duration
after which an artifact nobody downloaded gets evicted.
Ecosystems opt in one at a time: a handler registers with
retention.Default, and until it does, configuring that ecosystem (or one
of its packages) fails validation, and the default doesn't touch it. This
commit registers none, so nothing changes yet. Startup logs which
ecosystems the default covers and which it doesn't.
An artifact counts as expired when both its fetch time and its last
access are older than the cutoff. Checking only the last access would
evict a refetched artifact again right away, since clearing a record
keeps its old access time and a cache miss doesn't record a hit.
The sweep scans artifacts in id windows, so no single query holds the
SQLite connection for long, and checks each row against its own rule.
Expired records are cleared and their files go through pending_deletes
instead of being deleted directly, because a buffered hit can make an
artifact that's being served right now look expired. Buffered hits are
flushed before each sweep, and one sweep clears at most 10,000
artifacts.
Since a big first sweep can queue a lot, reclaim now keeps working
through due entries for up to 30 seconds per tick instead of stopping
after 100, and pending_deletes gets an index on queued_at.
Also:
- proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and
retention evictions.
- gradle.build_cache.max_age accepts "7d", as its comment always said.
Refs #306
* Document storage retention
Describes the new storage.retention block, the order rules apply in,
that the default only covers ecosystems that support retention yet, and
what to expect from the first sweep over an old cache. Also lists the
new eviction counter and notes that gradle max_age takes days.
Refs #306
* Fix retention timing on Postgres and tighten the sweep
On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns
without a zone. They hold the local time the proxy wrote, but lib/pq
reads them back labelled UTC. East of UTC that makes every artifact look
hours younger than it is, so the sweep evicted late: two hours in
Berlin, nine in Tokyo. The candidates' times are now read back as local
time, and the database tests run on both SQLite and Postgres, with one
that sets the local zone to Asia/Tokyo.
A few smaller things:
- When a delete fails, reclaim stops for the rest of the minute instead
of trying every queued path. Otherwise a storage outage turns into
thirty seconds of failed deletes and warnings every minute.
- If the buffered hits can't be written, the sweep skips that round.
Running on stale access times could clear something that is being
downloaded right now.
- DefaultCanonical only accepts a package key when rebuilding it gives
the same PURL. For alpine and deb it would otherwise produce keys
that never match.
- sweep_interval has to be at least a minute.
- New tests cover the eviction counter and an artifact that gets a hit
between the scan and the clear.
- The docs no longer say nothing changes without retention, and they
point out that the example's ecosystem and package rules only pass
validation once those ecosystems support retention.
Refs #306
* Reject qualifiers and subpaths in retention package keys
A rule names a whole package. Qualifiers like ?type=pom were dropped on
the way to the stored PURL and the rule quietly covered every file of the
package. Now such a key fails validation.
Refs #306
* Say when a package key doesn't match how packages are stored
A package rule for an ecosystem that supports retention, written in a
form its packages aren't stored under (pkg:npm/babel/core without the
@), failed with "retention is not supported for npm packages". Once the
ecosystem is enabled that is no longer true. Such a key now fails with
"does not match how npm packages are stored" and points to the package
rule forms in the docs. Keys for ecosystems without retention support
keep the old message.
Refs #306
* Note that re-running the mirror keeps packages cached
A mirror run over a package that is still cached goes through the normal
cache lookup and records a download, so the package's age starts over.
The docs only said that mirrored packages age from the time they were
mirrored.
Refs #306
* Say when an ecosystem takes no package rules
Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule.
Refs #306
* Use helm as the example of an ecosystem without package rules
Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart.
Refs #306
* Say that each cached file ages on its own
The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately.
Refs #306
* Clear an expired artifact and queue its file in one transaction
The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries.
Refs #306
* Show artifact evictions on the analytics page
proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order.
Refs #306
* Store artifact times in UTC
fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read.
Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early.
Refs #306
---------
Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
|
|
|
CREATE INDEX IF NOT EXISTS idx_pending_deletes_queued_at ON pending_deletes(queued_at);
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
|
2026-01-29 19:35:15 +00:00
|
|
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
vuln_id TEXT NOT NULL,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
package_name TEXT NOT NULL,
|
|
|
|
|
severity TEXT,
|
|
|
|
|
summary TEXT,
|
|
|
|
|
fixed_version TEXT,
|
|
|
|
|
cvss_score REAL,
|
|
|
|
|
"references" TEXT,
|
|
|
|
|
fetched_at TIMESTAMP,
|
|
|
|
|
created_at TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
|
2026-04-06 13:06:45 +01:00
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
CREATE TABLE IF NOT EXISTS metadata_cache (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT NOT NULL,
|
|
|
|
|
etag TEXT,
|
2026-09-02 17:10:35 +05:30
|
|
|
link TEXT,
|
2026-03-19 21:06:02 +00:00
|
|
|
content_type TEXT,
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
content_encoding TEXT,
|
2026-08-13 07:35:07 +01:00
|
|
|
content_digest TEXT,
|
2026-03-19 21:06:02 +00:00
|
|
|
size BIGINT,
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
last_modified TIMESTAMP,
|
2026-03-19 21:06:02 +00:00
|
|
|
fetched_at TIMESTAMP,
|
|
|
|
|
created_at TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
|
|
|
|
|
|
2026-10-12 12:52:02 +02:00
|
|
|
CREATE TABLE IF NOT EXISTS metadata_dates (
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
proxy_url TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
etag TEXT NOT NULL,
|
|
|
|
|
last_modified BIGINT NOT NULL,
|
|
|
|
|
PRIMARY KEY (ecosystem, proxy_url, name)
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
CREATE TABLE IF NOT EXISTS migrations (
|
|
|
|
|
name TEXT NOT NULL PRIMARY KEY,
|
|
|
|
|
applied_at TIMESTAMP NOT NULL
|
|
|
|
|
);
|
2026-01-29 16:44:01 +00:00
|
|
|
`
|
|
|
|
|
|
|
|
|
|
// schemaArtifactsOnly contains just the artifacts table for adding to existing git-pkgs databases.
|
|
|
|
|
var schemaArtifactsSQLite = `
|
|
|
|
|
CREATE TABLE IF NOT EXISTS artifacts (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
version_purl TEXT NOT NULL,
|
|
|
|
|
filename TEXT NOT NULL,
|
|
|
|
|
upstream_url TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT,
|
|
|
|
|
content_hash TEXT,
|
|
|
|
|
size INTEGER,
|
|
|
|
|
content_type TEXT,
|
|
|
|
|
fetched_at DATETIME,
|
|
|
|
|
hit_count INTEGER DEFAULT 0,
|
|
|
|
|
last_accessed_at DATETIME,
|
|
|
|
|
created_at DATETIME,
|
2026-10-13 12:35:22 +02:00
|
|
|
updated_at DATETIME,
|
2026-10-13 13:19:58 +02:00
|
|
|
source TEXT NOT NULL DEFAULT 'upstream',
|
|
|
|
|
manifest TEXT
|
2026-01-29 16:44:01 +00:00
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_version_filename ON artifacts(version_purl, filename);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_storage_path ON artifacts(storage_path);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_last_accessed ON artifacts(last_accessed_at);
|
|
|
|
|
`
|
2026-01-29 16:06:56 +00:00
|
|
|
|
2026-01-29 16:44:01 +00:00
|
|
|
var schemaArtifactsPostgres = `
|
2026-01-29 16:06:56 +00:00
|
|
|
CREATE TABLE IF NOT EXISTS artifacts (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
2026-01-29 16:44:01 +00:00
|
|
|
version_purl TEXT NOT NULL,
|
2026-01-29 16:06:56 +00:00
|
|
|
filename TEXT NOT NULL,
|
|
|
|
|
upstream_url TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT,
|
|
|
|
|
content_hash TEXT,
|
|
|
|
|
size BIGINT,
|
|
|
|
|
content_type TEXT,
|
|
|
|
|
fetched_at TIMESTAMP,
|
|
|
|
|
hit_count BIGINT DEFAULT 0,
|
|
|
|
|
last_accessed_at TIMESTAMP,
|
|
|
|
|
created_at TIMESTAMP,
|
2026-10-13 12:35:22 +02:00
|
|
|
updated_at TIMESTAMP,
|
2026-10-13 13:19:58 +02:00
|
|
|
source TEXT NOT NULL DEFAULT 'upstream',
|
|
|
|
|
manifest TEXT
|
2026-01-29 16:06:56 +00:00
|
|
|
);
|
2026-01-29 16:44:01 +00:00
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_version_filename ON artifacts(version_purl, filename);
|
2026-01-29 16:06:56 +00:00
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_storage_path ON artifacts(storage_path);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_artifacts_last_accessed ON artifacts(last_accessed_at);
|
|
|
|
|
`
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func (db *DB) CreateSchema() error {
|
|
|
|
|
if err := db.OptimizeForBulkWrites(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
var schema string
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
schema = schemaPostgres
|
|
|
|
|
} else {
|
|
|
|
|
schema = schemaSQLite
|
|
|
|
|
}
|
2026-01-20 21:52:44 +00:00
|
|
|
|
|
|
|
|
if _, err := db.Exec(schema); err != nil {
|
|
|
|
|
return fmt.Errorf("executing schema: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:06:56 +00:00
|
|
|
query := db.Rebind("INSERT INTO schema_info (version) VALUES (?)")
|
|
|
|
|
if _, err := db.Exec(query, SchemaVersion); err != nil {
|
2026-01-20 21:52:44 +00:00
|
|
|
return fmt.Errorf("setting schema version: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
// Record all migrations as applied since the full schema is already current.
|
|
|
|
|
if err := db.recordAllMigrations(); err != nil {
|
|
|
|
|
return fmt.Errorf("recording migrations: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
return db.OptimizeForReads()
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:44:01 +00:00
|
|
|
// EnsureArtifactsTable adds the artifacts table to an existing database
|
|
|
|
|
// (e.g., a git-pkgs database) if it doesn't already exist.
|
|
|
|
|
func (db *DB) EnsureArtifactsTable() error {
|
|
|
|
|
var schema string
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
schema = schemaArtifactsPostgres
|
|
|
|
|
} else {
|
|
|
|
|
schema = schemaArtifactsSQLite
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := db.Exec(schema); err != nil {
|
|
|
|
|
return fmt.Errorf("creating artifacts table: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
func (db *DB) SchemaVersion() (int, error) {
|
|
|
|
|
var version int
|
2026-01-29 16:06:56 +00:00
|
|
|
err := db.Get(&version, "SELECT version FROM schema_info LIMIT 1")
|
2026-01-20 21:52:44 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return 0, err
|
|
|
|
|
}
|
|
|
|
|
return version, nil
|
|
|
|
|
}
|
2026-01-29 16:44:01 +00:00
|
|
|
|
|
|
|
|
// HasTable checks if a table exists in the database.
|
|
|
|
|
func (db *DB) HasTable(name string) (bool, error) {
|
|
|
|
|
var exists bool
|
|
|
|
|
var query string
|
|
|
|
|
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
query = "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)"
|
|
|
|
|
} else {
|
|
|
|
|
query = "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name=?)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err := db.Get(&exists, query, name)
|
|
|
|
|
return exists, err
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
|
|
|
|
|
// HasColumn checks if a column exists in a table.
|
|
|
|
|
func (db *DB) HasColumn(table, column string) (bool, error) {
|
|
|
|
|
var exists bool
|
|
|
|
|
var query string
|
|
|
|
|
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
query = "SELECT EXISTS (SELECT FROM information_schema.columns WHERE table_name = $1 AND column_name = $2)"
|
|
|
|
|
} else {
|
|
|
|
|
// For SQLite, check table_info
|
|
|
|
|
query = "SELECT COUNT(*) > 0 FROM pragma_table_info(?) WHERE name = ?"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
err := db.Get(&exists, query, table, column)
|
|
|
|
|
return exists, err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
// migration represents a named schema migration.
|
|
|
|
|
type migration struct {
|
|
|
|
|
name string
|
|
|
|
|
fn func(db *DB) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// migrations is the ordered list of all schema migrations. See
|
|
|
|
|
// docs/migrations.md for how to add new ones.
|
|
|
|
|
var migrations = []migration{
|
|
|
|
|
{"001_add_packages_enrichment_columns", migrateAddPackagesEnrichmentColumns},
|
|
|
|
|
{"002_add_versions_enrichment_columns", migrateAddVersionsEnrichmentColumns},
|
|
|
|
|
{"003_ensure_artifacts_table", migrateEnsureArtifactsTable},
|
|
|
|
|
{"004_ensure_vulnerabilities_table", migrateEnsureVulnerabilitiesTable},
|
2026-03-19 21:06:02 +00:00
|
|
|
{"005_ensure_metadata_cache_table", migrateEnsureMetadataCacheTable},
|
2026-08-13 07:35:07 +01:00
|
|
|
{"006_add_metadata_content_digest", migrateAddMetadataContentDigest},
|
2026-09-02 17:10:35 +05:30
|
|
|
{"007_add_metadata_link", migrateAddMetadataLink},
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
{"008_add_metadata_content_encoding", migrateAddMetadataContentEncoding},
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
{"009_add_pending_deletes", migrateAddPendingDeletes},
|
Add age-based retention for cached artifacts (#428)
* Add age-based retention for cached artifacts
Cached artifacts stayed forever unless storage.max_size pushed them out.
storage.retention adds a default, per-ecosystem and per-package duration
after which an artifact nobody downloaded gets evicted.
Ecosystems opt in one at a time: a handler registers with
retention.Default, and until it does, configuring that ecosystem (or one
of its packages) fails validation, and the default doesn't touch it. This
commit registers none, so nothing changes yet. Startup logs which
ecosystems the default covers and which it doesn't.
An artifact counts as expired when both its fetch time and its last
access are older than the cutoff. Checking only the last access would
evict a refetched artifact again right away, since clearing a record
keeps its old access time and a cache miss doesn't record a hit.
The sweep scans artifacts in id windows, so no single query holds the
SQLite connection for long, and checks each row against its own rule.
Expired records are cleared and their files go through pending_deletes
instead of being deleted directly, because a buffered hit can make an
artifact that's being served right now look expired. Buffered hits are
flushed before each sweep, and one sweep clears at most 10,000
artifacts.
Since a big first sweep can queue a lot, reclaim now keeps working
through due entries for up to 30 seconds per tick instead of stopping
after 100, and pending_deletes gets an index on queued_at.
Also:
- proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and
retention evictions.
- gradle.build_cache.max_age accepts "7d", as its comment always said.
Refs #306
* Document storage retention
Describes the new storage.retention block, the order rules apply in,
that the default only covers ecosystems that support retention yet, and
what to expect from the first sweep over an old cache. Also lists the
new eviction counter and notes that gradle max_age takes days.
Refs #306
* Fix retention timing on Postgres and tighten the sweep
On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns
without a zone. They hold the local time the proxy wrote, but lib/pq
reads them back labelled UTC. East of UTC that makes every artifact look
hours younger than it is, so the sweep evicted late: two hours in
Berlin, nine in Tokyo. The candidates' times are now read back as local
time, and the database tests run on both SQLite and Postgres, with one
that sets the local zone to Asia/Tokyo.
A few smaller things:
- When a delete fails, reclaim stops for the rest of the minute instead
of trying every queued path. Otherwise a storage outage turns into
thirty seconds of failed deletes and warnings every minute.
- If the buffered hits can't be written, the sweep skips that round.
Running on stale access times could clear something that is being
downloaded right now.
- DefaultCanonical only accepts a package key when rebuilding it gives
the same PURL. For alpine and deb it would otherwise produce keys
that never match.
- sweep_interval has to be at least a minute.
- New tests cover the eviction counter and an artifact that gets a hit
between the scan and the clear.
- The docs no longer say nothing changes without retention, and they
point out that the example's ecosystem and package rules only pass
validation once those ecosystems support retention.
Refs #306
* Reject qualifiers and subpaths in retention package keys
A rule names a whole package. Qualifiers like ?type=pom were dropped on
the way to the stored PURL and the rule quietly covered every file of the
package. Now such a key fails validation.
Refs #306
* Say when a package key doesn't match how packages are stored
A package rule for an ecosystem that supports retention, written in a
form its packages aren't stored under (pkg:npm/babel/core without the
@), failed with "retention is not supported for npm packages". Once the
ecosystem is enabled that is no longer true. Such a key now fails with
"does not match how npm packages are stored" and points to the package
rule forms in the docs. Keys for ecosystems without retention support
keep the old message.
Refs #306
* Note that re-running the mirror keeps packages cached
A mirror run over a package that is still cached goes through the normal
cache lookup and records a download, so the package's age starts over.
The docs only said that mirrored packages age from the time they were
mirrored.
Refs #306
* Say when an ecosystem takes no package rules
Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule.
Refs #306
* Use helm as the example of an ecosystem without package rules
Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart.
Refs #306
* Say that each cached file ages on its own
The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately.
Refs #306
* Clear an expired artifact and queue its file in one transaction
The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries.
Refs #306
* Show artifact evictions on the analytics page
proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order.
Refs #306
* Store artifact times in UTC
fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read.
Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early.
Refs #306
---------
Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
|
|
|
{"010_add_pending_deletes_queued_at_index", migrateAddPendingDeletesQueuedAtIndex},
|
2026-10-12 12:52:02 +02:00
|
|
|
{"011_add_metadata_dates", migrateAddMetadataDates},
|
2026-10-13 12:35:22 +02:00
|
|
|
{"012_add_artifact_source", migrateAddArtifactSource},
|
2026-10-13 13:19:58 +02:00
|
|
|
{"013_add_artifact_manifest", migrateAddArtifactManifest},
|
2026-04-06 13:06:45 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isTableNotFound returns true if the error indicates a missing table.
|
|
|
|
|
// SQLite returns "no such table: X", Postgres returns "relation \"X\" does not exist".
|
|
|
|
|
func isTableNotFound(err error) bool {
|
|
|
|
|
msg := err.Error()
|
|
|
|
|
return strings.Contains(msg, "no such table") ||
|
|
|
|
|
strings.Contains(msg, "does not exist")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// createMigrationsTable creates the migrations table.
|
|
|
|
|
func (db *DB) createMigrationsTable() error {
|
|
|
|
|
var ts string
|
|
|
|
|
if db.dialect == DialectPostgres {
|
2026-05-05 10:25:17 +01:00
|
|
|
ts = postgresTimestamp
|
2026-04-06 13:06:45 +01:00
|
|
|
} else {
|
2026-05-05 10:25:17 +01:00
|
|
|
ts = sqliteDatetime
|
2026-04-06 13:06:45 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS migrations (
|
|
|
|
|
name TEXT NOT NULL PRIMARY KEY,
|
|
|
|
|
applied_at %s NOT NULL
|
|
|
|
|
)`, ts)
|
|
|
|
|
|
|
|
|
|
if _, err := db.Exec(query); err != nil {
|
|
|
|
|
return fmt.Errorf("creating migrations table: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// appliedMigrations returns the set of migration names that have been recorded.
|
|
|
|
|
// Returns nil if the migrations table does not exist yet.
|
|
|
|
|
func (db *DB) appliedMigrations() (map[string]bool, error) {
|
|
|
|
|
var names []string
|
|
|
|
|
err := db.Select(&names, "SELECT name FROM migrations")
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Table doesn't exist yet — this is a pre-migration database.
|
|
|
|
|
if isTableNotFound(err) {
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("loading applied migrations: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
applied := make(map[string]bool, len(names))
|
|
|
|
|
for _, name := range names {
|
|
|
|
|
applied[name] = true
|
|
|
|
|
}
|
|
|
|
|
return applied, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// recordMigration inserts a migration name into the migrations table.
|
|
|
|
|
func (db *DB) recordMigration(name string) error {
|
|
|
|
|
query := db.Rebind("INSERT INTO migrations (name, applied_at) VALUES (?, ?)")
|
|
|
|
|
if _, err := db.Exec(query, name, time.Now().UTC()); err != nil {
|
|
|
|
|
return fmt.Errorf("recording migration %s: %w", name, err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// recordAllMigrations marks every known migration as applied.
|
|
|
|
|
func (db *DB) recordAllMigrations() error {
|
|
|
|
|
for _, m := range migrations {
|
|
|
|
|
if err := db.recordMigration(m.name); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MigrateSchema applies any unapplied migrations in order.
|
|
|
|
|
// For a fully migrated database this executes a single SELECT query.
|
2026-02-03 22:40:23 +00:00
|
|
|
func (db *DB) MigrateSchema() error {
|
2026-04-06 13:06:45 +01:00
|
|
|
applied, err := db.appliedMigrations()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the migrations table didn't exist, create it now.
|
|
|
|
|
if applied == nil {
|
|
|
|
|
if err := db.createMigrationsTable(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
applied = make(map[string]bool)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, m := range migrations {
|
|
|
|
|
if applied[m.name] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := m.fn(db); err != nil {
|
|
|
|
|
return fmt.Errorf("migration %s: %w", m.name, err)
|
|
|
|
|
}
|
|
|
|
|
if err := db.recordMigration(m.name); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func migrateAddPackagesEnrichmentColumns(db *DB) error {
|
|
|
|
|
columns := map[string]string{
|
2026-05-05 10:25:17 +01:00
|
|
|
"registry_url": colTypeText,
|
|
|
|
|
"supplier_name": colTypeText,
|
|
|
|
|
"supplier_type": colTypeText,
|
|
|
|
|
"source": colTypeText,
|
|
|
|
|
"enriched_at": sqliteDatetime,
|
|
|
|
|
"vulns_synced_at": sqliteDatetime,
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if db.dialect == DialectPostgres {
|
2026-04-06 13:06:45 +01:00
|
|
|
columns["enriched_at"] = postgresTimestamp
|
|
|
|
|
columns["vulns_synced_at"] = postgresTimestamp
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
for column, colType := range columns {
|
2026-02-03 22:40:23 +00:00
|
|
|
hasCol, err := db.HasColumn("packages", column)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking column %s: %w", column, err)
|
|
|
|
|
}
|
|
|
|
|
if !hasCol {
|
2026-03-18 10:59:29 +00:00
|
|
|
alterQuery := fmt.Sprintf("ALTER TABLE packages ADD COLUMN %s %s", column, colType)
|
2026-02-03 22:40:23 +00:00
|
|
|
if _, err := db.Exec(alterQuery); err != nil {
|
|
|
|
|
return fmt.Errorf("adding column %s to packages: %w", column, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 13:06:45 +01:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
func migrateAddVersionsEnrichmentColumns(db *DB) error {
|
|
|
|
|
columns := map[string]string{
|
2026-05-05 10:25:17 +01:00
|
|
|
"integrity": colTypeText,
|
2026-02-03 22:40:23 +00:00
|
|
|
"yanked": "INTEGER DEFAULT 0",
|
2026-05-05 10:25:17 +01:00
|
|
|
"source": colTypeText,
|
|
|
|
|
"enriched_at": sqliteDatetime,
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if db.dialect == DialectPostgres {
|
2026-04-06 13:06:45 +01:00
|
|
|
columns["yanked"] = "BOOLEAN DEFAULT FALSE"
|
|
|
|
|
columns["enriched_at"] = postgresTimestamp
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
for column, colType := range columns {
|
2026-02-03 22:40:23 +00:00
|
|
|
hasCol, err := db.HasColumn("versions", column)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking column %s: %w", column, err)
|
|
|
|
|
}
|
|
|
|
|
if !hasCol {
|
2026-03-18 10:59:29 +00:00
|
|
|
alterQuery := fmt.Sprintf("ALTER TABLE versions ADD COLUMN %s %s", column, colType)
|
2026-02-03 22:40:23 +00:00
|
|
|
if _, err := db.Exec(alterQuery); err != nil {
|
|
|
|
|
return fmt.Errorf("adding column %s to versions: %w", column, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 13:06:45 +01:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
func migrateEnsureArtifactsTable(db *DB) error {
|
|
|
|
|
return db.EnsureArtifactsTable()
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
func migrateEnsureVulnerabilitiesTable(db *DB) error {
|
2026-02-03 22:40:23 +00:00
|
|
|
hasVulns, err := db.HasTable("vulnerabilities")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking vulnerabilities table: %w", err)
|
|
|
|
|
}
|
2026-04-06 13:06:45 +01:00
|
|
|
if hasVulns {
|
|
|
|
|
return nil
|
2026-02-03 22:40:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 13:06:45 +01:00
|
|
|
var vulnSchema string
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
vulnSchema = `
|
|
|
|
|
CREATE TABLE vulnerabilities (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
vuln_id TEXT NOT NULL,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
package_name TEXT NOT NULL,
|
|
|
|
|
severity TEXT,
|
|
|
|
|
summary TEXT,
|
|
|
|
|
fixed_version TEXT,
|
|
|
|
|
cvss_score REAL,
|
|
|
|
|
"references" TEXT,
|
|
|
|
|
fetched_at TIMESTAMP,
|
|
|
|
|
created_at TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
|
|
|
|
|
`
|
|
|
|
|
} else {
|
|
|
|
|
vulnSchema = `
|
|
|
|
|
CREATE TABLE vulnerabilities (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
vuln_id TEXT NOT NULL,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
package_name TEXT NOT NULL,
|
|
|
|
|
severity TEXT,
|
|
|
|
|
summary TEXT,
|
|
|
|
|
fixed_version TEXT,
|
|
|
|
|
cvss_score REAL,
|
|
|
|
|
"references" TEXT,
|
|
|
|
|
fetched_at DATETIME,
|
|
|
|
|
created_at DATETIME,
|
|
|
|
|
updated_at DATETIME
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_vulns_id_pkg ON vulnerabilities(vuln_id, ecosystem, package_name);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_vulns_ecosystem_pkg ON vulnerabilities(ecosystem, package_name);
|
|
|
|
|
`
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec(vulnSchema); err != nil {
|
|
|
|
|
return fmt.Errorf("creating vulnerabilities table: %w", err)
|
|
|
|
|
}
|
2026-03-19 21:06:02 +00:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func migrateEnsureMetadataCacheTable(db *DB) error {
|
|
|
|
|
return db.EnsureMetadataCacheTable()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 07:35:07 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 17:10:35 +05:30
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
func migrateAddMetadataContentEncoding(db *DB) error {
|
|
|
|
|
hasColumn, err := db.HasColumn("metadata_cache", "content_encoding")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking metadata_cache content_encoding column: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if hasColumn {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec("ALTER TABLE metadata_cache ADD COLUMN content_encoding TEXT"); err != nil {
|
|
|
|
|
return fmt.Errorf("adding metadata_cache content_encoding column: %w", err)
|
|
|
|
|
}
|
|
|
|
|
// Rows cached before this column existed hold transport-decompressed bytes
|
|
|
|
|
// with no recorded encoding and the upstream ETag captured under Go's
|
|
|
|
|
// auto-added Accept-Encoding: gzip. Clearing the validators forces one fresh
|
|
|
|
|
// fetch per key so the encoding is recorded and verbatim bytes are restored,
|
|
|
|
|
// instead of an If-None-Match 304 re-serving the stale decompressed copy.
|
|
|
|
|
if _, err := db.Exec("UPDATE metadata_cache SET etag = NULL, fetched_at = NULL"); err != nil {
|
|
|
|
|
return fmt.Errorf("invalidating metadata_cache validators: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// migrateAddPendingDeletes creates the queue of storage paths that no record
|
|
|
|
|
// points at any more, which the server deletes after a grace period.
|
|
|
|
|
func migrateAddPendingDeletes(db *DB) error {
|
|
|
|
|
ts := sqliteDatetime
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
ts = postgresTimestamp
|
|
|
|
|
}
|
|
|
|
|
query := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS pending_deletes (
|
|
|
|
|
path TEXT NOT NULL PRIMARY KEY,
|
|
|
|
|
queued_at %s NOT NULL
|
|
|
|
|
)`, ts)
|
|
|
|
|
if _, err := db.Exec(query); err != nil {
|
|
|
|
|
return fmt.Errorf("creating pending_deletes table: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
Add age-based retention for cached artifacts (#428)
* Add age-based retention for cached artifacts
Cached artifacts stayed forever unless storage.max_size pushed them out.
storage.retention adds a default, per-ecosystem and per-package duration
after which an artifact nobody downloaded gets evicted.
Ecosystems opt in one at a time: a handler registers with
retention.Default, and until it does, configuring that ecosystem (or one
of its packages) fails validation, and the default doesn't touch it. This
commit registers none, so nothing changes yet. Startup logs which
ecosystems the default covers and which it doesn't.
An artifact counts as expired when both its fetch time and its last
access are older than the cutoff. Checking only the last access would
evict a refetched artifact again right away, since clearing a record
keeps its old access time and a cache miss doesn't record a hit.
The sweep scans artifacts in id windows, so no single query holds the
SQLite connection for long, and checks each row against its own rule.
Expired records are cleared and their files go through pending_deletes
instead of being deleted directly, because a buffered hit can make an
artifact that's being served right now look expired. Buffered hits are
flushed before each sweep, and one sweep clears at most 10,000
artifacts.
Since a big first sweep can queue a lot, reclaim now keeps working
through due entries for up to 30 seconds per tick instead of stopping
after 100, and pending_deletes gets an index on queued_at.
Also:
- proxy_artifacts_evicted_total{reason, ecosystem} counts LRU and
retention evictions.
- gradle.build_cache.max_age accepts "7d", as its comment always said.
Refs #306
* Document storage retention
Describes the new storage.retention block, the order rules apply in,
that the default only covers ecosystems that support retention yet, and
what to expect from the first sweep over an old cache. Also lists the
new eviction counter and notes that gradle max_age takes days.
Refs #306
* Fix retention timing on Postgres and tighten the sweep
On Postgres, fetched_at and last_accessed_at are TIMESTAMP columns
without a zone. They hold the local time the proxy wrote, but lib/pq
reads them back labelled UTC. East of UTC that makes every artifact look
hours younger than it is, so the sweep evicted late: two hours in
Berlin, nine in Tokyo. The candidates' times are now read back as local
time, and the database tests run on both SQLite and Postgres, with one
that sets the local zone to Asia/Tokyo.
A few smaller things:
- When a delete fails, reclaim stops for the rest of the minute instead
of trying every queued path. Otherwise a storage outage turns into
thirty seconds of failed deletes and warnings every minute.
- If the buffered hits can't be written, the sweep skips that round.
Running on stale access times could clear something that is being
downloaded right now.
- DefaultCanonical only accepts a package key when rebuilding it gives
the same PURL. For alpine and deb it would otherwise produce keys
that never match.
- sweep_interval has to be at least a minute.
- New tests cover the eviction counter and an artifact that gets a hit
between the scan and the clear.
- The docs no longer say nothing changes without retention, and they
point out that the example's ecosystem and package rules only pass
validation once those ecosystems support retention.
Refs #306
* Reject qualifiers and subpaths in retention package keys
A rule names a whole package. Qualifiers like ?type=pom were dropped on
the way to the stored PURL and the rule quietly covered every file of the
package. Now such a key fails validation.
Refs #306
* Say when a package key doesn't match how packages are stored
A package rule for an ecosystem that supports retention, written in a
form its packages aren't stored under (pkg:npm/babel/core without the
@), failed with "retention is not supported for npm packages". Once the
ecosystem is enabled that is no longer true. Such a key now fails with
"does not match how npm packages are stored" and points to the package
rule forms in the docs. Keys for ecosystems without retention support
keep the old message.
Refs #306
* Note that re-running the mirror keeps packages cached
A mirror run over a package that is still cached goes through the normal
cache lookup and records a download, so the package's age starts over.
The docs only said that mirrored packages age from the time they were
mirrored.
Refs #306
* Say when an ecosystem takes no package rules
Some ecosystems will support retention without package rules, because nothing in their stored records names a package the way a PURL would (conan, helm). A package key for one of them used to fail with "does not match how conan packages are stored; see the package rule forms", which sends people looking for a form that doesn't exist. It now says package rules aren't supported there and points to the ecosystem rule.
Refs #306
* Use helm as the example of an ecosystem without package rules
Conan gets package rules once its handler stores archives under the recipe name, so the config test that shows the refusal now uses helm, whose cache records name the repository, not the chart.
Refs #306
* Say that each cached file ages on its own
The retention docs said each artifact, each version of a package, ages on its own. A version can have several cached files (a jar and its pom, several wheels, Conan sources and binaries), and each of them ages separately.
Refs #306
* Clear an expired artifact and queue its file in one transaction
The sweep cleared the record and queued the file for deletion as two separate writes. If queueing failed, or the process stopped in between, the file stayed in storage with no record pointing at it and no queue entry, and no later sweep would find it again. Both writes now share a transaction, so a failed queue rolls the clear back and the next sweep retries.
Refs #306
* Show artifact evictions on the analytics page
proxy_artifacts_evicted_total had no place on /ui/analytics. The Runtime card now lists evicted artifacts by reason and ecosystem, without the failure tint, and the coverage test records an eviction so it doesn't depend on test order.
Refs #306
* Store artifact times in UTC
fetched_at and last_accessed_at are now written in UTC, and the retention cutoffs are compared in UTC, the same convention #434 uses for the metadata cache. lib/pq sends a time with its offset and a Postgres TIMESTAMP keeps only the wall clock, so UTC on write is what reads back correctly. That replaces reinterpreting the times as local on read.
Rows written before this still hold local time. East of UTC they look younger by the offset until they are rewritten, so retention evicts them a few hours late once; west of UTC that much early.
Refs #306
---------
Co-authored-by: t <t@t>
2026-10-12 10:06:01 +02:00
|
|
|
// migrateAddPendingDeletesQueuedAtIndex indexes the column reclaim orders
|
|
|
|
|
// the queue by, which grows large after a retention sweep.
|
|
|
|
|
func migrateAddPendingDeletesQueuedAtIndex(db *DB) error {
|
|
|
|
|
_, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_pending_deletes_queued_at ON pending_deletes(queued_at)`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("creating pending_deletes queued_at index: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 12:52:02 +02:00
|
|
|
// migrateAddMetadataDates creates the table of Last-Modified dates for
|
|
|
|
|
// rewritten metadata, which replicas sharing the database agree on.
|
|
|
|
|
func migrateAddMetadataDates(db *DB) error {
|
|
|
|
|
seconds := "INTEGER"
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
seconds = "BIGINT"
|
|
|
|
|
}
|
|
|
|
|
query := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS metadata_dates (
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
proxy_url TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
etag TEXT NOT NULL,
|
|
|
|
|
last_modified %s NOT NULL,
|
|
|
|
|
PRIMARY KEY (ecosystem, proxy_url, name)
|
|
|
|
|
)`, seconds)
|
|
|
|
|
if _, err := db.Exec(query); err != nil {
|
|
|
|
|
return fmt.Errorf("creating metadata_dates table: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 21:06:02 +00:00
|
|
|
// EnsureMetadataCacheTable creates the metadata_cache table if it doesn't exist.
|
|
|
|
|
func (db *DB) EnsureMetadataCacheTable() error {
|
|
|
|
|
has, err := db.HasTable("metadata_cache")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking metadata_cache table: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if has {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var schema string
|
|
|
|
|
if db.dialect == DialectPostgres {
|
|
|
|
|
schema = `
|
|
|
|
|
CREATE TABLE metadata_cache (
|
|
|
|
|
id SERIAL PRIMARY KEY,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT NOT NULL,
|
2026-09-02 17:10:35 +05:30
|
|
|
etag TEXT,
|
|
|
|
|
link TEXT,
|
|
|
|
|
content_type TEXT,
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
content_encoding TEXT,
|
2026-08-13 07:35:07 +01:00
|
|
|
content_digest TEXT,
|
2026-03-19 21:06:02 +00:00
|
|
|
size BIGINT,
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
last_modified TIMESTAMP,
|
2026-03-19 21:06:02 +00:00
|
|
|
fetched_at TIMESTAMP,
|
|
|
|
|
created_at TIMESTAMP,
|
|
|
|
|
updated_at TIMESTAMP
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
|
|
|
|
|
`
|
|
|
|
|
} else {
|
|
|
|
|
schema = `
|
|
|
|
|
CREATE TABLE metadata_cache (
|
|
|
|
|
id INTEGER PRIMARY KEY,
|
|
|
|
|
ecosystem TEXT NOT NULL,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
storage_path TEXT NOT NULL,
|
2026-09-02 17:10:35 +05:30
|
|
|
etag TEXT,
|
|
|
|
|
link TEXT,
|
|
|
|
|
content_type TEXT,
|
fix(handler): preserve upstream content-encoding for cached metadata (#304)
* fix(handler): preserve upstream content-encoding for cached metadata
Signed and hash-pinned index files (debian Release/Packages.gz, rpm
repomd.xml, helm index.yaml, conda repodata.json, apk APKINDEX.tar.gz)
were cached after Go's transport transparently decompressed any
Content-Encoding: gzip response, so the proxy served bytes that differ
from what the upstream signed and broke client verification.
Request the identity encoding on the shared metadata fetch so Go no
longer auto-decompresses, and persist the upstream Content-Encoding in
a new metadata_cache column so both the cached and offline responses
replay the exact bytes and header. The uncached streaming path now
forwards Content-Encoding too.
Fixes #300
* fix(handler): scope verbatim metadata fetch to the ProxyCached path
The first cut forced Accept-Encoding: identity in the shared
fetchUpstreamMetadata, which an adversarial review showed both missed
the bug and regressed unrelated ecosystems:
- proxyMetadataStream (the default path, since cache_metadata is off)
never forced identity, so a client sending no Accept-Encoding still
triggered Go's transparent gzip decompression and served altered bytes
for apk/debian/rpm/conda indexes.
- Direct FetchOrCacheMetadata callers that parse or rewrite the body
(npm, pypi, cargo, composer, pub, nuget, swift, maven, helm) were
forced to identity too, losing wire compression and 502-ing against
upstreams that ignore identity and gzip anyway (helm rewriteIndex).
- Rows cached before the fix kept serving decompressed bytes via ETag
304 revalidation.
Scope the verbatim behavior to the ProxyCached code path, which serves
upstream bytes through unchanged (apk, debian, rpm, go, hex, conda,
cran, gem, conan, julia). That path now requests identity on both the
cached fetch and the uncached stream branch and replays Content-Encoding;
direct callers keep transparent compression, matching main. Migration
008 clears etag/fetched_at so legacy rows refetch once with identity.
Tests now exercise the stream path with no client Accept-Encoding and
pin that direct callers are not forced to identity.
2026-09-03 10:58:58 +02:00
|
|
|
content_encoding TEXT,
|
2026-08-13 07:35:07 +01:00
|
|
|
content_digest TEXT,
|
2026-03-19 21:06:02 +00:00
|
|
|
size INTEGER,
|
Fix metadata caching, 404 propagation, mirror progress, and registry stubs
- ProxyCached now stores upstream Last-Modified in the cache and uses it
(along with ETag) for conditional request handling, returning 304 when
client validators match. Adds Content-Length to cached responses.
- Handlers calling FetchOrCacheMetadata (pypi, composer, pub, nuget) now
check for ErrUpstreamNotFound and return 404 instead of 502, matching
the existing npm and cargo behavior.
- Mirror jobs report live progress via a periodic callback while running,
so API polls return real counts instead of zeroed progress.
- Registry mirroring removed from CLI flags, API acceptance, README, and
docs since every enumerator was a stub returning "not yet implemented".
- Added tests for the conditional metadata path (ETag/If-None-Match,
Last-Modified/If-Modified-Since, 304 responses, header omission).
2026-04-01 20:14:11 +01:00
|
|
|
last_modified DATETIME,
|
2026-03-19 21:06:02 +00:00
|
|
|
fetched_at DATETIME,
|
|
|
|
|
created_at DATETIME,
|
|
|
|
|
updated_at DATETIME
|
|
|
|
|
);
|
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_metadata_eco_name ON metadata_cache(ecosystem, name);
|
|
|
|
|
`
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec(schema); err != nil {
|
|
|
|
|
return fmt.Errorf("creating metadata_cache table: %w", err)
|
|
|
|
|
}
|
2026-02-03 22:40:23 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-13 12:35:22 +02:00
|
|
|
|
|
|
|
|
// migrateAddArtifactSource records whether an artifact is a cached copy of an
|
|
|
|
|
// upstream registry or was pushed to the proxy. Existing rows are all upstream.
|
|
|
|
|
func migrateAddArtifactSource(db *DB) error {
|
|
|
|
|
hasCol, err := db.HasColumn("artifacts", "source")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking column source: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if hasCol {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec(`ALTER TABLE artifacts ADD COLUMN source TEXT NOT NULL DEFAULT 'upstream'`); err != nil {
|
|
|
|
|
return fmt.Errorf("adding column source to artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-10-13 13:19:58 +02:00
|
|
|
|
|
|
|
|
// migrateAddArtifactManifest stores the manifest read from a pushed archive,
|
|
|
|
|
// which the metadata of its ecosystem lists the version with.
|
|
|
|
|
func migrateAddArtifactManifest(db *DB) error {
|
|
|
|
|
hasCol, err := db.HasColumn("artifacts", "manifest")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("checking column manifest: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if hasCol {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if _, err := db.Exec(`ALTER TABLE artifacts ADD COLUMN manifest TEXT`); err != nil {
|
|
|
|
|
return fmt.Errorf("adding column manifest to artifacts: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|