From ed6216d78b0882b589e1a984efcb9155a213862e Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 17 Oct 2026 12:46:14 +1100 Subject: [PATCH] Serve deb virtual indexes by hash and bound cached suites --- e2e-docker/virtual_test.go | 61 ++++++++++++++++++++++--- internal/virtual/deb_merger.go | 32 ++++++++----- internal/virtual/deb_merger_test.go | 32 ++++++++++--- internal/virtual/engine.go | 30 +++++++++++-- internal/virtual/engine_test.go | 69 +++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 27 deletions(-) diff --git a/e2e-docker/virtual_test.go b/e2e-docker/virtual_test.go index 2916b3a..f736ef8 100644 --- a/e2e-docker/virtual_test.go +++ b/e2e-docker/virtual_test.go @@ -152,19 +152,21 @@ func TestVirtualDebMerge(t *testing.T) { uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package") uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package") - createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`) - defer deleteVirtual(t, "vdeb") - - var pk string + // Local indexes regenerate asynchronously; merge only once vdeb-b lists both. deadline := time.Now().Add(15 * time.Second) for { - resp, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "") - pk = string(body) - if resp.StatusCode == http.StatusOK && strings.Contains(pk, "vdeb-two") || time.Now().After(deadline) { + resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "") + if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) { break } time.Sleep(500 * time.Millisecond) } + + createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`) + defer deleteVirtual(t, "vdeb") + + _, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "") + pk := string(body) if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") || !strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") { t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk) @@ -192,3 +194,48 @@ func TestVirtualDebMerge(t *testing.T) { t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out) } } + +// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a +// real apt through the virtual's dists/ layout (by-hash indexes). +func TestVirtualDebSuite(t *testing.T) { + network := os.Getenv("COMPOSE_NETWORK") + internal := os.Getenv("ARTIFACTAPI_INTERNAL") + if network == "" || internal == "" { + t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available on the test host") + } + for _, n := range []string{"vdebs-a", "vdebs-b"} { + createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`) + defer deleteRepo(t, n) + } + createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`) + defer deleteVirtual(t, "vdebs") + + resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "") + if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") || + !strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") { + t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel) + } + + script := "set -euo pipefail; " + + "rm -f /etc/apt/sources.list.d/*; " + + "echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " + + "apt-get update; " + + "idx=/tmp/avail; apt-cache dumpavail > $idx; " + + "grep -q '^Filename: vdebs-a/pool/' $idx; ! grep -q '^Filename: vdebs-b/' $idx; " + + "pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2/binary-/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 diff --git a/internal/virtual/deb_merger_test.go b/internal/virtual/deb_merger_test.go index 5f9e138..64b2dc3 100644 --- a/internal/virtual/deb_merger_test.go +++ b/internal/virtual/deb_merger_test.go @@ -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}, diff --git a/internal/virtual/engine.go b/internal/virtual/engine.go index 9add6fd..1c2761f 100644 --- a/internal/virtual/engine.go +++ b/internal/virtual/engine.go @@ -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 } } diff --git a/internal/virtual/engine_test.go b/internal/virtual/engine_test.go index 7f8cd19..04a6a82 100644 --- a/internal/virtual/engine_test.go +++ b/internal/virtual/engine_test.go @@ -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") + } +}