Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/server/retention.go
simon chrzanowski 1979fb098e
Accept Swift source repository PURLs in the denylist and retention rules (#641)
* Accept Swift source repository PURLs in the denylist and retention rules

Closes #613. pkg:swift/github.com/apple/swift-nio, as SBOM tools and GHSA
write Swift packages, is resolved to its registry identity through the
upstream's /identifiers:

- Denylist entries are resolved at startup, and the proxy refuses to start
  when one can't be, since a skipped entry would let the version through.
- Retention keys are accepted at validation and kept pending. The
  retention loop resolves them before each sweep, and Swift isn't swept
  while any is pending, so a rule that keeps a package is never skipped.

The mirror's source URL helper moves to the handler as SwiftSourceURL.

* Resolve Swift source denylist entries in proxy mirror too

serve resolved source-form Swift denylist entries through the upstream,
but proxy mirror built its denylist directly, so such an entry never
matched a mirror download. Both now use server.ConfigureDenylist, and
neither runs when an entry can't be resolved.

* Apply Swift source policies to every listed identity

A Swift registry may list several identities for one source repository,
but a denylist entry or retention rule naming the repository only
covered the first. Denying pkg:swift/github.com/acme/widget@1.0.0 let
acme.widget through when the registry listed other.widget first, and a
never-evict rule cleared the cached apple.swift-nio archive when
other.swift-nio came first.

Both now cover every identity the registry lists. A retention rule that
conflicts with another rule for any of them stays pending, with none
applied. Mirroring a source PURL still downloads the first identity.
2026-10-31 11:34:05 +00:00

182 lines
5.3 KiB
Go

package server
import (
"context"
"log/slog"
"slices"
"time"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/retention"
)
// retentionWindow is how many artifact ids one query scans.
const retentionWindow = 5000
// retentionMaxClears caps how many artifacts one sweep clears, so a first
// sweep over a large, old cache spreads its deletes and the refetches that
// follow over several intervals. A variable so tests can lower it.
var retentionMaxClears = 10000
func (s *Server) startRetentionLoop(ctx context.Context, resolvers map[string]sourceResolver) {
reg := retention.Default
rules, err := s.cfg.RetentionRules(reg)
if err != nil {
// Validate already rejected an invalid config at startup.
s.logger.Error("retention: invalid configuration, sweep disabled", "error", err)
return
}
if rules.MinPositive() == 0 {
return
}
covered := reg.Keys()
if len(covered) == 0 {
// Only a default can be set without any registered ecosystem;
// validation rejects every other rule.
s.logger.Info("retention: no ecosystem supports retention in this version, sweep disabled",
"default_not_applied", notCovered(covered))
return
}
s.logger.Info("retention enabled",
"covered", covered,
"default_not_applied", notCovered(covered),
"sweep_interval", s.cfg.ParseRetentionSweepInterval())
ticker := time.NewTicker(s.cfg.ParseRetentionSweepInterval())
defer ticker.Stop()
sweep := func() {
resolveRetentionSources(ctx, s.logger, resolvers, rules)
s.sweepRetention(ctx, reg, rules)
}
sweep()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
sweep()
}
}
}
func (s *Server) sweepRetention(ctx context.Context, reg *retention.Registry, rules retention.Rules) {
s.runExclusive(ctx, "retention", func() {
sweepRetention(ctx, s.db, s.logger, reg, rules, time.Now())
})
}
// notCovered returns the known ecosystem keys the global default cannot reach.
func notCovered(covered []string) []string {
var missing []string
for _, key := range retention.KnownKeys {
if key == "generic" || !slices.Contains(covered, key) {
missing = append(missing, key)
}
}
return missing
}
// sweepRetention clears cached artifacts that have outlived their retention
// as of now and queues their objects for deletion. Objects are not deleted
// directly: a hit still buffered when the scan ran can make an artifact that
// is being served look expired, and the queue's grace period covers that.
func sweepRetention(ctx context.Context, db *database.DB, logger *slog.Logger,
reg *retention.Registry, rules retention.Rules, now time.Time) {
minimum := rules.MinPositive()
if minimum == 0 {
return
}
// Without the buffered hits written, an artifact being served right now
// can look expired, so skip this sweep rather than clear it.
if err := db.FlushHits(); err != nil {
logger.Warn("retention: failed to write buffered hits, skipping this sweep", "error", err)
return
}
maxID, err := db.MaxArtifactID()
if err != nil {
logger.Warn("retention: failed to read artifact ids", "error", err)
return
}
// Rows that are not expired under the shortest rule cannot be expired
// under any, so the query uses that cutoff and each row is then checked
// against its own rule.
scanCutoff := now.Add(-minimum)
cleared := 0
freed := int64(0)
capped := false
for from := int64(0); from < maxID && !capped; from += retentionWindow {
if ctx.Err() != nil {
return
}
candidates, err := db.GetRetentionCandidates(from, from+retentionWindow, scanCutoff)
if err != nil {
logger.Warn("retention: failed to scan artifacts", "from_id", from, "error", err)
return
}
for _, c := range candidates {
if ctx.Err() != nil {
return
}
if cleared >= retentionMaxClears {
capped = true
break
}
if !clearIfExpired(db, logger, reg, rules, now, c) {
continue
}
metrics.RecordArtifactEvicted("retention", c.Ecosystem)
cleared++
if c.Size.Valid {
freed += c.Size.Int64
}
}
}
if cleared > 0 {
logger.Info("retention: sweep completed",
"cleared", cleared, "freed_bytes", freed, "capped", capped)
}
}
// clearIfExpired clears one candidate when its own rule says it has expired
// and queues its object for deletion, reporting whether it did.
func clearIfExpired(db *database.DB, logger *slog.Logger, reg *retention.Registry,
rules retention.Rules, now time.Time, c database.RetentionCandidate) bool {
key, ok := reg.KeyForDBEcosystem(c.Ecosystem)
if !ok || rules.Pending(key) {
return false
}
d := rules.For(key, c.PackagePURL)
if d <= 0 {
return false
}
cutoff := now.Add(-d)
if !c.ExpiredBefore(cutoff) {
return false
}
cleared, err := db.ClearExpiredArtifact(c.VersionPURL, c.Filename, c.StoragePath, cutoff)
if err != nil {
logger.Warn("retention: failed to clear artifact record",
"version_purl", c.VersionPURL, "filename", c.Filename, "error", err)
return false
}
return cleared
}
// recordLRUEviction counts an artifact the size limit evicted under the
// ecosystem of its package record.
func recordLRUEviction(db *database.DB, logger *slog.Logger, versionPURL string) {
ecosystem, err := db.GetArtifactEcosystem(versionPURL)
if err != nil {
logger.Debug("eviction: failed to look up ecosystem for metrics", "version_purl", versionPURL, "error", err)
}
metrics.RecordArtifactEvicted("lru", ecosystem)
}