v0.6.0 aba298d

store_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package registryproxy
4
5import (
6	"context"
7	"fmt"
8	"io"
9	"net/http"
10	"net/http/httptest"
11	"os"
12	"path/filepath"
13	"strings"
14	"testing"
15	"time"
16)
17
18// Reopening a store keeps its content, charges it again and clears the
19// incomplete downloads in tmp/.
20func TestStoreReopen(t *testing.T) {
21	dir := t.TempDir()
22	s := mustOpen(t, storeCfg(dir, 8*minCharge))
23	put(t, s, "aaaa")
24	putManifest(t, s, manifestBody, "application/vnd.oci.image.index.v1+json")
25	if err := s.setTag("library/alpine", "3", normalizeAccept(""), sum(manifestBody)); err != nil {
26		t.Fatal(err)
27	}
28	if err := os.WriteFile(filepath.Join(dir, "tmp", "leftover"), []byte("partial"), 0600); err != nil {
29		t.Fatal(err)
30	}
31	total := s.total
32	s = mustOpen(t, storeCfg(dir, 8*minCharge))
33	f, _, err := s.open(kindBlob, sum("aaaa"))
34	if err != nil {
35		t.Fatal(err)
36	}
37	_ = f.Close()
38	if got := s.tag("library/alpine", "3", normalizeAccept("")); got != sum(manifestBody) {
39		t.Fatalf("tag after reopen: %q", got)
40	}
41	if ds, _ := os.ReadDir(filepath.Join(dir, "tmp")); len(ds) != 0 {
42		t.Fatal("tmp not cleared")
43	}
44	if accounted(t, s); s.total != total {
45		t.Fatalf("reopened total %d, was %d", s.total, total)
46	}
47}
48
49// Every entry is charged at least minCharge, on commit and on reopen, so tiny
50// blobs cannot make more than Max/minCharge files.
51func TestEntriesChargedAtLeastMinCharge(t *testing.T) {
52	dir := t.TempDir()
53	s := mustOpen(t, storeCfg(dir, 3*minCharge))
54	for i := range 10 {
55		put(t, s, fmt.Sprint("tiny-", i))
56	}
57	accounted(t, s)
58	if n := len(s.entries); n > 3 {
59		t.Fatalf("%d entries under a cap of 3 charges", n)
60	}
61	s = mustOpen(t, storeCfg(dir, 3*minCharge))
62	accounted(t, s)
63	if s.total != int64(len(s.entries))*minCharge {
64		t.Fatalf("reopened total %d for %d entries", s.total, len(s.entries))
65	}
66}
67
68// A cap below one entry's minimum charge is refused; a blob larger than Max is
69// refused and leaves nothing charged.
70func TestStoreOversize(t *testing.T) {
71	if _, err := openStore(storeCfg(t.TempDir(), minCharge-1)); err == nil {
72		t.Fatal("cap below minCharge accepted")
73	}
74	s := mustOpen(t, storeCfg(t.TempDir(), minCharge))
75	large := strings.Repeat("x", int(minCharge)+1)
76	_, _, _, err := s.ensure(context.Background(), kindBlob, sum(large), func(_ context.Context, w io.Writer) (string, error) {
77		_, err := io.WriteString(w, large)
78		return "", err
79	}, keepalive{})
80	if err != errTooLarge {
81		t.Fatal(err)
82	}
83	if s.pending != 0 || s.total != 0 {
84		t.Fatalf("accounting: %d %d", s.pending, s.total)
85	}
86}
87
88// Eviction walks the LRU, evicts least recently used first, frees down to the
89// low-water mark rather than the bare need, and skips pinned entries.
90func TestEvictionLRUAndLowWater(t *testing.T) {
91	now := time.Unix(1_700_000_000, 0)
92	const n = 40
93	c := storeCfg(t.TempDir(), n*minCharge) // low water: 5%, two entries
94	c.now = func() time.Time { return now }
95	s := mustOpen(t, c)
96	// 40 blobs fill the store. Freeing only the need would evict one blob; the
97	// low-water mark evicts two.
98	blob := func(i int) string { return fmt.Sprintf("%03d", i) + strings.Repeat("x", 47) }
99	for i := range n {
100		now = now.Add(time.Second)
101		put(t, s, blob(i))
102	}
103	// Touch 0 and 1, and keep 2 open: the least recently used are then 3, 4...
104	for _, i := range []int{0, 1} {
105		now = now.Add(time.Second)
106		f, _, err := s.open(kindBlob, sum(blob(i)))
107		if err != nil {
108			t.Fatal(err)
109		}
110		_ = f.Close()
111	}
112	pinned, _, err := s.open(kindBlob, sum(blob(2)))
113	if err != nil {
114		t.Fatal(err)
115	}
116	defer func() { _ = pinned.Close() }()
117	s.mu.Lock()
118	s.lru.MoveToFront(s.entries[kindBlob.key(sum(blob(2))[7:])].elem) // oldest of all, but open
119	s.mu.Unlock()
120	now = now.Add(time.Second)
121	put(t, s, "a") // over the cap: frees the low-water mark
122	present := func(i int) bool {
123		_, err := os.Stat(filepath.Join(s.dir, kindBlob.key(sum(blob(i))[7:])))
124		return err == nil
125	}
126	for i := range n {
127		want := i != 3 && i != 4 // 3 and 4 are the least recently used unpinned
128		if present(i) != want {
129			t.Errorf("blob %d present=%v", i, present(i))
130		}
131	}
132	accounted(t, s)
133	s.mu.Lock()
134	free := s.max - s.total
135	s.mu.Unlock()
136	if free < min(lowWater, s.max/20)-minCharge {
137		t.Fatalf("freed only to %d", free)
138	}
139}
140
141// Eviction restarts from the front after a manifest removal that fails, and
142// does not loop on it.
143func TestEvictionAfterFailedManifestRemoval(t *testing.T) {
144	now := time.Unix(1_700_000_000, 0)
145	c := storeCfg(t.TempDir(), 1<<20)
146	c.now = func() time.Time { return now }
147	s := mustOpen(t, c)
148	m := `{"schemaVersion":2}`
149	putManifest(t, s, m, "application/vnd.oci.image.index.v1+json")
150	now = now.Add(time.Second)
151	if err := s.setTag("library/alpine", "3", "", sum(m)); err != nil {
152		t.Fatal(err)
153	}
154	now = now.Add(time.Second)
155	put(t, s, "blob")
156	// The manifest's .type cannot be removed: replace it with a non-empty
157	// directory. Its tag goes first all the same.
158	typ := filepath.Join(s.dir, kindManifest.key(sum(m)[7:])+".type")
159	if err := os.Remove(typ); err != nil {
160		t.Fatal(err)
161	}
162	if err := os.MkdirAll(filepath.Join(typ, "x"), 0700); err != nil {
163		t.Fatal(err)
164	}
165	s.mu.Lock()
166	s.evictLocked(s.max) // ask for everything
167	_, manifestKept := s.entries[kindManifest.key(sum(m)[7:])]
168	_, blobKept := s.entries[kindBlob.key(sum("blob")[7:])]
169	tags := len(s.tagsOf)
170	s.mu.Unlock()
171	if !manifestKept || blobKept || tags != 0 {
172		t.Fatalf("manifest kept=%v blob kept=%v tags=%d", manifestKept, blobKept, tags)
173	}
174	accounted(t, s)
175}
176
177// The committed entry is pinned for its waiters, so an eviction between the
178// commit and their open cannot remove it.
179func TestCommittedEntryPinnedForWaiters(t *testing.T) {
180	s := mustOpen(t, storeCfg(t.TempDir(), minCharge))
181	key := kindBlob.key(sum("aaaa")[7:])
182	s.hookCommitted = func(string) {
183		// Ask for the whole store before the waiters are woken.
184		s.mu.Lock()
185		s.evictLocked(s.max)
186		s.mu.Unlock()
187	}
188	f, _, _, err := s.ensure(context.Background(), kindBlob, sum("aaaa"), func(_ context.Context, w io.Writer) (string, error) {
189		_, err := io.WriteString(w, "aaaa")
190		return "", err
191	}, keepalive{})
192	if err != nil {
193		t.Fatalf("waiter lost its entry: %v", err)
194	}
195	s.mu.Lock()
196	pins := s.entries[key].pins
197	s.mu.Unlock()
198	if pins != 1 {
199		t.Fatalf("pins %d: the commit pin was not released to the reader's", pins)
200	}
201	_ = f.Close()
202	s.hookCommitted = nil
203	s.mu.Lock()
204	s.evictLocked(s.max)
205	_, kept := s.entries[key]
206	s.mu.Unlock()
207	if kept {
208		t.Fatal("unpinned entry not evictable")
209	}
210}
211
212// A blob that a response is still sending is pinned: a fill that needs its
213// room fails, and succeeds once the response has closed the file.
214func TestEvictionWhileServing(t *testing.T) {
215	a, b := strings.Repeat("a", int(minCharge)), strings.Repeat("b", int(minCharge))
216	srv := upstreamWith(t, func(w http.ResponseWriter, r *http.Request) {
217		switch {
218		case strings.HasSuffix(r.URL.Path, sum(a)):
219			_, _ = io.WriteString(w, a)
220		case strings.HasSuffix(r.URL.Path, sum(b)):
221			_, _ = io.WriteString(w, b)
222		default:
223			http.NotFound(w, r)
224		}
225	})
226	p := proxyWith(t, srv, Config{Max: minCharge * 3 / 2})
227	if w := request(t, p, http.MethodGet, "/v2/library/alpine/blobs/"+sum(a)); w.Code != http.StatusOK {
228		t.Fatal(w.Code)
229	}
230	bw := &blockingWriter{ResponseRecorder: httptest.NewRecorder(), started: make(chan struct{}), release: make(chan struct{})}
231	done := make(chan struct{})
232	go func() {
233		defer close(done)
234		p.ServeHTTP(bw, httptest.NewRequest(http.MethodGet, "/v2/library/alpine/blobs/"+sum(a), nil))
235	}()
236	<-bw.started
237	if w := request(t, p, http.MethodGet, "/v2/library/alpine/blobs/"+sum(b)); w.Code != http.StatusBadGateway {
238		t.Fatalf("fill evicted a blob being served: %d", w.Code)
239	}
240	if _, err := os.Stat(filepath.Join(p.s.dir, kindBlob.key(sum(a)[7:]))); err != nil {
241		t.Fatalf("served blob removed: %v", err)
242	}
243	accounted(t, p.s)
244	close(bw.release)
245	<-done
246	if bw.Code != http.StatusOK || bw.Body.String() != a {
247		t.Fatalf("served: %d", bw.Code)
248	}
249	if w := request(t, p, http.MethodGet, "/v2/library/alpine/blobs/"+sum(b)); w.Code != http.StatusOK || w.Body.String() != b {
250		t.Fatalf("after release: %d", w.Code)
251	}
252	if _, err := os.Stat(filepath.Join(p.s.dir, kindBlob.key(sum(a)[7:]))); !os.IsNotExist(err) {
253		t.Fatal("released blob not evicted")
254	}
255	accounted(t, p.s)
256}
257
258// Tag files share the LRU of the store, so they cannot grow the store past
259// Max; reopening charges them again.
260func TestTagsChargedAndEvicted(t *testing.T) {
261	now := time.Unix(1_700_000_000, 0)
262	dir := t.TempDir()
263	c := storeCfg(dir, 4*minCharge) // the manifest and 3 tags
264	c.now = func() time.Time { return now }
265	s := mustOpen(t, c)
266	putManifest(t, s, manifestBody, "application/vnd.oci.image.index.v1+json")
267	for i := range 20 {
268		now = now.Add(time.Second)
269		if err := s.setTag("library/alpine", fmt.Sprint("t", i), "", sum(manifestBody)); err != nil {
270			t.Fatalf("tag %d: %v", i, err)
271		}
272		if n := accounted(t, s); n > 3 {
273			t.Fatalf("%d tags over a cap of 3", n)
274		}
275	}
276	if s.tag("library/alpine", "t19", "") != sum(manifestBody) || s.tag("library/alpine", "t0", "") != "" {
277		t.Fatal("tags not evicted least recently used first")
278	}
279	s = mustOpen(t, storeCfg(dir, 4*minCharge))
280	if n := accounted(t, s); n != 3 {
281		t.Fatalf("reopened with %d tags", n)
282	}
283}
284
285// The tag index follows rewrites, and removing a manifest takes exactly its
286// own tags along.
287func TestTagIndex(t *testing.T) {
288	s := mustOpen(t, storeCfg(t.TempDir(), 1<<20))
289	manifests := []string{`{"schemaVersion":2,"a":1}`, `{"schemaVersion":2,"b":1}`}
290	for _, m := range manifests {
291		putManifest(t, s, m, "application/vnd.oci.image.index.v1+json")
292	}
293	for _, tag := range []string{"x", "y"} {
294		if err := s.setTag("library/alpine", tag, "", sum(manifests[0])); err != nil {
295			t.Fatal(err)
296		}
297	}
298	// y moves to the second manifest.
299	if err := s.setTag("library/alpine", "y", "", sum(manifests[1])); err != nil {
300		t.Fatal(err)
301	}
302	s.mu.Lock()
303	if !s.removeLocked(kindManifest.key(sum(manifests[0])[7:])) {
304		t.Fatal("remove failed")
305	}
306	n0, n1 := len(s.tagsOf[sum(manifests[0])]), len(s.tagsOf[sum(manifests[1])])
307	s.mu.Unlock()
308	if s.tag("library/alpine", "x", "") != "" || s.tag("library/alpine", "y", "") != sum(manifests[1]) || n0 != 0 || n1 != 1 {
309		t.Fatalf("index: %d %d", n0, n1)
310	}
311	accounted(t, s)
312}
313
314// A .type file without its manifest is removed at startup.
315func TestOrphanTypeRemoved(t *testing.T) {
316	dir := t.TempDir()
317	mustOpen(t, storeCfg(dir, 1<<20))
318	orphan := filepath.Join(dir, kindManifest.key(sum("gone")[7:])+".type")
319	if err := os.WriteFile(orphan, []byte("application/json"), 0600); err != nil {
320		t.Fatal(err)
321	}
322	s := mustOpen(t, storeCfg(dir, 1<<20))
323	if _, err := os.Stat(orphan); !os.IsNotExist(err) {
324		t.Fatal("orphan .type kept")
325	}
326	accounted(t, s)
327}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close