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}