Serve deb virtual indexes by hash and bound cached suites
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

This commit is contained in:
2026-10-17 12:46:14 +11:00
parent eca686b7bb
commit ed6216d78b
5 changed files with 197 additions and 27 deletions
+21 -11
View File
@@ -5,6 +5,7 @@ import (
"crypto/md5"
"encoding/hex"
"fmt"
"path"
"regexp"
"slices"
"strings"
@@ -15,6 +16,9 @@ import (
// repo's root Packages and a suite's <component>/binary-<arch>/Packages.
var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`)
// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash).
var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`)
// debCompressions is the order a member's index variants are fetched in.
var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""}
@@ -127,9 +131,9 @@ func parseDebDate(s string) (time.Time, bool) {
}
// MergeDeb merges members into one apt repo. Members are in priority order: the
// first member to carry a (Package, Version, Architecture) in an index wins.
// Filename is prefixed with the owning member's name so the virtual can route
// downloads back to it.
// first member to carry a (Package, Version, Architecture) in an index wins;
// stanzas missing any of those are always kept. Filename is prefixed with the
// owning member's name so the virtual can route downloads back to it.
func MergeDeb(members []DebMember) *DebRepo {
var suite, codename string
var arches, comps, dirs []string
@@ -158,20 +162,26 @@ func MergeDeb(members []DebMember) *DebRepo {
var b bytes.Buffer
for _, m := range members {
for _, s := range splitStanzas(m.Packages[d]) {
key := stanzaField(s, "Package") + "\x00" + stanzaField(s, "Version") + "\x00" + stanzaField(s, "Architecture")
if seen[key] {
continue
pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture")
if pkg != "" && ver != "" && arch != "" {
key := pkg + "\x00" + ver + "\x00" + arch
if seen[key] {
continue
}
seen[key] = true
}
seen[key] = true
if fn := stanzaField(s, "Filename"); fn != "" {
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimLeft(fn, "/"))
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/"))
}
b.WriteString(s)
b.WriteString("\n\n")
}
}
gz := gzipDeterministic(b.Bytes())
repo.Files[d+"Packages"] = b.Bytes()
repo.Files[d+"Packages.gz"] = gzipDeterministic(b.Bytes())
repo.Files[d+"Packages.gz"] = gz
repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes()
repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz
}
var r bytes.Buffer
@@ -186,7 +196,7 @@ func MergeDeb(members []DebMember) *DebRepo {
if len(comps) > 0 {
fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " "))
}
r.WriteString("Acquire-By-Hash: no\n")
r.WriteString("Acquire-By-Hash: yes\n")
for _, sec := range []struct {
name string
sum func([]byte) string
@@ -223,7 +233,7 @@ func debIndexPath(p string) (base, name string, ok bool) {
}
base, p = "dists/"+suite+"/", n
}
if p != "Release" && !debIndexRe.MatchString(p) {
if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) {
return "", "", false
}
return base, p, true
+27 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"maps"
"path"
"slices"
"strings"
"testing"
@@ -24,7 +25,10 @@ func TestMergeDebDedupesByMemberOrder(t *testing.T) {
repo := MergeDeb([]DebMember{
flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000",
debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"),
debStanza("bar", "2.0", "amd64", "/pool/bar.deb")),
debStanza("bar", "2.0", "amd64", "/pool/bar.deb"),
debStanza("dot", "1.0", "amd64", "./dot.deb"),
"Package: noversion\nFilename: nv1.deb\n",
"Package: noversion\nFilename: nv2.deb\n"),
flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC",
debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"),
debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"),
@@ -32,12 +36,15 @@ func TestMergeDebDedupesByMemberOrder(t *testing.T) {
})
pk := string(repo.Files["Packages"])
stanzas := splitStanzas(repo.Files["Packages"])
if len(stanzas) != 4 {
t.Fatalf("want 4 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
if len(stanzas) != 7 {
t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
}
for _, want := range []string{
"Filename: a/pool/foo_1.0_amd64.deb",
"Filename: a/pool/bar.deb",
"Filename: a/dot.deb",
"Filename: a/nv1.deb",
"Filename: a/nv2.deb",
"Filename: b/pool/foo_arm64.deb",
"Filename: b/o/r/releases/download/v1/foo.deb",
" multi-line body",
@@ -111,8 +118,20 @@ func TestMergeDebSuiteLayout(t *testing.T) {
}
}
want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}
if got := slices.Sorted(maps.Keys(repo.Files)); !slices.Equal(got, want) {
t.Fatalf("files %v, want %v", got, want)
if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) {
t.Fatalf("Release lists %v, want %v", got, want)
}
if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") {
t.Fatalf("Release must advertise by-hash:\n%s", repo.Release)
}
for _, n := range want {
byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n])
if !bytes.Equal(repo.Files[byHash], repo.Files[n]) {
t.Errorf("%s not served at %s", n, byHash)
}
}
if len(repo.Files) != 2*len(want) {
t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files)))
}
if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 {
t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n)
@@ -142,6 +161,9 @@ func TestDebIndexPath(t *testing.T) {
{"Packages.gz", "", "Packages.gz", true},
{"dists/trixie/Release", "dists/trixie/", "Release", true},
{"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true},
{"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
{"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
{"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false},
{"InRelease", "", "", false},
{"Release.gpg", "", "", false},
{"dists/trixie/InRelease", "", "", false},
+26 -4
View File
@@ -51,11 +51,14 @@ func (r *DebRepo) files() map[string][]byte { return r.Files }
// holding the previous index can still fetch the files it references.
type repoGen struct {
cur, prev mergedRepo
at time.Time
at, used time.Time
}
const rpmMergeTTL = 60 * time.Second
// maxDebSuites bounds the client-chosen suites cached per deb virtual.
const maxDebSuites = 32
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
e.rpmMember = e.fetchRPMMember
@@ -276,6 +279,7 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont
e.mu.Lock()
g := e.gens[key]
if g != nil && time.Since(g.at) < e.mergeTTL {
g.used = time.Now()
e.mu.Unlock()
return g.cur, nil
}
@@ -294,12 +298,12 @@ func (e *Engine) merged(ctx context.Context, key string, merge func(context.Cont
g := e.gens[key]
switch {
case g == nil:
e.gens[key] = &repoGen{cur: repo, at: time.Now()}
e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()}
case string(g.cur.index()) == string(repo.index()):
g.at = time.Now()
g.at, g.used = time.Now(), time.Now()
repo = g.cur
default:
g.prev, g.cur, g.at = g.cur, repo, time.Now()
g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now()
}
return repo, nil
})
@@ -368,6 +372,7 @@ func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath stri
if err != nil {
return nil, "", err
}
e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites)
if name == "Release" {
return repo.index(), "text/plain", nil
}
@@ -408,6 +413,22 @@ func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMem
return m, nil
}
// evictLRU drops the least recently used merges under prefix beyond keep.
func (e *Engine) evictLRU(prefix string, keep int) {
e.mu.Lock()
defer e.mu.Unlock()
var keys []string
for k := range e.gens {
if strings.HasPrefix(k, prefix) {
keys = append(keys, k)
}
}
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) })
for _, k := range keys[min(len(keys), keep):] {
delete(e.gens, k)
}
}
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -420,6 +441,7 @@ func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
continue
}
if body, ok := r.files()[path]; ok {
g.used = time.Now()
return body, true
}
}
+69
View File
@@ -298,3 +298,72 @@ func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) {
t.Error("unknown key served a file")
}
}
func suiteDebMember(name, version string) *DebMember {
return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"),
Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}}
}
func TestFetchDebByHashSurvivesRemerge(t *testing.T) {
version := "1"
e := fakeEngine(nil)
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil }
virt := debVirt("a")
fetch := func(p string) []byte {
body, _, err := e.Fetch(context.Background(), virt, p, "")
if err != nil {
t.Fatalf("%s: %v", p, err)
}
return body
}
byHash := func(rel []byte) string {
m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel)
if m == nil {
t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel)
}
return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1])
}
oldRel := fetch("dists/trixie/Release")
version, e.mergeTTL = "2", 0
if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) {
t.Fatal("Release should reflect the member change")
}
if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") {
t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got)
}
if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") {
t.Fatalf("fixed-name Packages should be the current merge:\n%s", got)
}
}
func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) {
e := fakeEngine(nil)
e.mergeTTL = time.Minute
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil }
other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}}
if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil {
t.Fatal(err)
}
for i := range maxDebSuites + 5 {
if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil {
t.Fatal(err)
}
time.Sleep(time.Microsecond)
}
n := 0
for k := range e.gens {
if strings.HasPrefix(k, "deb\x00v\x00") {
n++
}
}
if n != maxDebSuites {
t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites)
}
if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil {
t.Fatal("least recently used suites should be evicted first")
}
if e.gens["deb\x00other\x00dists/keep/"] == nil {
t.Fatal("another virtual's suites must not be evicted")
}
}