master
go 452 lines 13.4 KB
Raw
1 package migrations
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "testing"
18 )
19
20 func TestGetDistPath(t *testing.T) {
21 os.Unsetenv(envIpfsDistPath)
22 distPath := GetDistPathEnv("")
23 if distPath != LatestIpfsDist {
24 t.Error("did not set default dist path")
25 }
26
27 testDist := "/unit/test/dist"
28 t.Setenv(envIpfsDistPath, testDist)
29 defer func() {
30 os.Unsetenv(envIpfsDistPath)
31 }()
32
33 distPath = GetDistPathEnv("")
34 if distPath != testDist {
35 t.Error("did not set dist path from environ")
36 }
37 distPath = GetDistPathEnv("ignored")
38 if distPath != testDist {
39 t.Error("did not set dist path from environ")
40 }
41
42 testDist = "/unit/test/dist2"
43 fetcher := NewHttpFetcher(testDist, "", "", 0)
44 if fetcher.distPath != testDist {
45 t.Error("did not set dist path")
46 }
47 }
48
49 func TestHttpFetch(t *testing.T) {
50 ctx := t.Context()
51
52 fetcher := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
53
54 out, err := fetcher.Fetch(ctx, "/kubo/versions")
55 if err != nil {
56 t.Fatal(err)
57 }
58
59 var lines []string
60 scan := bufio.NewScanner(bytes.NewReader(out))
61 for scan.Scan() {
62 lines = append(lines, scan.Text())
63 }
64 err = scan.Err()
65 if err != nil {
66 t.Fatal("could not read versions:", err)
67 }
68
69 if len(lines) < 6 {
70 t.Fatal("do not get all expected data")
71 }
72 if lines[0] != "v1.0.0" {
73 t.Fatal("expected v1.0.0 as first line, got", lines[0])
74 }
75
76 // Check not found
77 _, err = fetcher.Fetch(ctx, "/no_such_file")
78 if err == nil || !strings.Contains(err.Error(), "no link") {
79 t.Fatal("expected error 404")
80 }
81 }
82
83 func TestFetchBinary(t *testing.T) {
84 tmpDir := t.TempDir()
85
86 ctx := t.Context()
87
88 fetcher := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
89
90 vers, err := DistVersions(ctx, fetcher, distFSRM, false)
91 if err != nil {
92 t.Fatal(err)
93 }
94 t.Log("latest version of", distFSRM, "is", vers[len(vers)-1])
95
96 bin, err := FetchBinary(ctx, fetcher, distFSRM, vers[0], "", tmpDir)
97 if err != nil {
98 t.Fatal(err)
99 }
100
101 fi, err := os.Stat(bin)
102 if os.IsNotExist(err) {
103 t.Error("expected file to exist:", bin)
104 }
105
106 t.Log("downloaded and unpacked", fi.Size(), "byte file:", fi.Name())
107
108 bin, err = FetchBinary(ctx, fetcher, "go-ipfs", "v1.0.0", "ipfs", tmpDir)
109 if err != nil {
110 t.Fatal(err)
111 }
112
113 fi, err = os.Stat(bin)
114 if os.IsNotExist(err) {
115 t.Error("expected file to exist:", bin)
116 }
117
118 t.Log("downloaded and unpacked", fi.Size(), "byte file:", fi.Name())
119
120 // Check error is destination already exists and is not directory
121 _, err = FetchBinary(ctx, fetcher, "go-ipfs", "v1.0.0", "ipfs", bin)
122 if !os.IsExist(err) {
123 t.Fatal("expected 'exists' error, got", err)
124 }
125
126 _, err = FetchBinary(ctx, fetcher, "go-ipfs", "v1.0.0", "ipfs", tmpDir)
127 if !os.IsExist(err) {
128 t.Error("expected 'exists' error, got:", err)
129 }
130
131 os.Remove(filepath.Join(tmpDir, ExeName("ipfs")))
132
133 // Check error creating temp download directory
134 //
135 // Windows doesn't have read-only directories https://github.com/golang/go/issues/35042 this would need to be
136 // tested another way
137 if runtime.GOOS != "windows" {
138 err = os.Chmod(tmpDir, 0o555)
139 if err != nil {
140 panic(err)
141 }
142 t.Setenv("TMPDIR", tmpDir)
143 _, err = FetchBinary(ctx, fetcher, "go-ipfs", "v1.0.0", "ipfs", tmpDir)
144 if !os.IsPermission(err) {
145 t.Error("expected 'permission' error, got:", err)
146 }
147 t.Setenv("TMPDIR", "/tmp")
148 err = os.Chmod(tmpDir, 0o755)
149 if err != nil {
150 panic(err)
151 }
152 }
153
154 // Check error if failure to fetch due to bad dist
155 _, err = FetchBinary(ctx, fetcher, "not-here", "v1.0.0", "ipfs", tmpDir)
156 if err == nil || !strings.Contains(err.Error(), "no link") {
157 t.Error("expected 'Not Found' error, got:", err)
158 }
159
160 // Check error if failure to unpack archive
161 _, err = FetchBinary(ctx, fetcher, "go-ipfs", "v1.0.0", "not-such-bin", tmpDir)
162 if err == nil || err.Error() != "no binary found in archive" {
163 t.Error("expected 'no binary found in archive' error")
164 }
165 }
166
167 // TestHttpFetcherUserAgent guards against a regression where NewHttpFetcher
168 // accepts a userAgent parameter but forgets to store it on the struct,
169 // silently sending Go's default "Go-http-client/1.1" instead of the
170 // migration agent string.
171 func TestHttpFetcherUserAgent(t *testing.T) {
172 const wantUA = "kubo/migration"
173
174 var gotUA string
175 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176 gotUA = r.Header.Get("User-Agent")
177 w.WriteHeader(http.StatusNotFound)
178 }))
179 defer srv.Close()
180
181 fetcher := NewHttpFetcher("/ipfs/bafyreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", srv.URL, wantUA, 0)
182 _, _ = fetcher.Fetch(t.Context(), "/anything")
183
184 if gotUA != wantUA {
185 t.Fatalf("User-Agent: got %q, want %q", gotUA, wantUA)
186 }
187 }
188
189 // TestMigrationDownloadSourcesFailover is an end-to-end check that two
190 // gateways listed in Migration.DownloadSources (here passed straight into
191 // GetMigrationFetcher, the same path ReadMigrationConfig feeds) cooperate via
192 // MultiFetcher: when the first gateway either errors with 404 or returns
193 // bytes that don't parse as a CAR, the second gateway is attempted and the
194 // migration data flows through.
195 func TestMigrationDownloadSourcesFailover(t *testing.T) {
196 ctx := t.Context()
197
198 t.Run("first gateway returns 404", func(t *testing.T) {
199 var badHits atomic.Int64
200 bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
201 badHits.Add(1)
202 http.Error(w, "not found", http.StatusNotFound)
203 }))
204 defer bad.Close()
205
206 // Migration.DownloadSources order: bad first, real test gateway second.
207 fetcher, err := GetMigrationFetcher([]string{bad.URL, testServer.URL}, testIpfsDist, nil)
208 if err != nil {
209 t.Fatalf("GetMigrationFetcher: %v", err)
210 }
211 defer fetcher.Close()
212
213 out, err := fetcher.Fetch(ctx, "/kubo/versions")
214 if err != nil {
215 t.Fatalf("expected failover to second gateway, got: %v", err)
216 }
217 if len(out) < 6 {
218 t.Fatalf("second gateway should have served the versions file, got %d bytes", len(out))
219 }
220 if badHits.Load() == 0 {
221 t.Fatal("first gateway was never tried; the failover path did not actually run")
222 }
223 })
224
225 t.Run("first gateway returns invalid CAR bytes", func(t *testing.T) {
226 var badHits atomic.Int64
227 bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
228 badHits.Add(1)
229 w.Header().Set("Content-Type", "application/vnd.ipld.car")
230 w.WriteHeader(http.StatusOK)
231 _, _ = w.Write([]byte("this is definitely not a valid CAR file"))
232 }))
233 defer bad.Close()
234
235 fetcher, err := GetMigrationFetcher([]string{bad.URL, testServer.URL}, testIpfsDist, nil)
236 if err != nil {
237 t.Fatalf("GetMigrationFetcher: %v", err)
238 }
239 defer fetcher.Close()
240
241 out, err := fetcher.Fetch(ctx, "/kubo/versions")
242 if err != nil {
243 t.Fatalf("expected failover to second gateway, got: %v", err)
244 }
245 if len(out) < 6 {
246 t.Fatalf("second gateway should have served the versions file, got %d bytes", len(out))
247 }
248 if badHits.Load() == 0 {
249 t.Fatal("first gateway was never tried; the failover path did not actually run")
250 }
251 })
252 }
253
254 func TestMultiFetcher(t *testing.T) {
255 ctx := t.Context()
256
257 badFetcher := NewHttpFetcher("", "bad-url", "", 0)
258 fetcher := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
259
260 mf := NewMultiFetcher(badFetcher, fetcher)
261
262 vers, err := mf.Fetch(ctx, "/kubo/versions")
263 if err != nil {
264 t.Fatal(err)
265 }
266
267 if len(vers) < 45 {
268 fmt.Println("unexpected more data")
269 }
270 }
271
272 // TestMultiFetcherQuarantine verifies that a fetcher which fails once is
273 // moved to the back of the rotation on subsequent calls, so a dead gateway
274 // does not cost the full HTTP timeout on every parallel migration download.
275 func TestMultiFetcherQuarantine(t *testing.T) {
276 ctx := t.Context()
277
278 tracker := &countingFetcher{}
279 good := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
280 mf := NewMultiFetcher(tracker, good)
281
282 // First call: tracker is healthy, gets tried first, fails. good takes over.
283 if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
284 t.Fatalf("first fetch: %v", err)
285 }
286 if got := tracker.calls.Load(); got != 1 {
287 t.Fatalf("expected tracker to be tried once, got %d", got)
288 }
289
290 // Second call: tracker is quarantined and must not be tried while good
291 // is still healthy.
292 if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
293 t.Fatalf("second fetch: %v", err)
294 }
295 if got := tracker.calls.Load(); got != 1 {
296 t.Fatalf("expected tracker to stay quarantined, got %d calls", got)
297 }
298 }
299
300 // TestMultiFetcherQuarantineReset verifies that when every fetcher fails in a
301 // single Fetch call, the quarantine resets so the next call retries all
302 // fetchers from scratch rather than inheriting a fully poisoned set.
303 func TestMultiFetcherQuarantineReset(t *testing.T) {
304 ctx := t.Context()
305
306 a := &countingFetcher{}
307 b := &countingFetcher{}
308 mf := NewMultiFetcher(a, b)
309
310 if _, err := mf.Fetch(ctx, "/anything"); err == nil {
311 t.Fatal("expected error when all fetchers fail")
312 }
313 if ca, cb := a.calls.Load(), b.calls.Load(); ca != 1 || cb != 1 {
314 t.Fatalf("first call: expected each fetcher tried once, got a=%d b=%d", ca, cb)
315 }
316
317 if _, err := mf.Fetch(ctx, "/anything"); err == nil {
318 t.Fatal("expected error when all fetchers fail")
319 }
320 // After the total wipeout, both should have been retried fresh, not
321 // skipped as quarantined.
322 if ca, cb := a.calls.Load(), b.calls.Load(); ca != 2 || cb != 2 {
323 t.Fatalf("second call after reset: expected each fetcher tried again, got a=%d b=%d", ca, cb)
324 }
325 }
326
327 // TestMultiFetcherExhaustionCap verifies the MultiFetcher gives up after
328 // maxMultiFetcherFullLoopFailures full rotations, returning
329 // ErrMultiFetcherExhausted without trying inner fetchers again.
330 func TestMultiFetcherExhaustionCap(t *testing.T) {
331 ctx := t.Context()
332
333 a := &countingFetcher{}
334 b := &countingFetcher{}
335 mf := NewMultiFetcher(a, b)
336
337 // Three failed full rotations should latch the breaker.
338 for i := range maxMultiFetcherFullLoopFailures {
339 if _, err := mf.Fetch(ctx, "/x"); err == nil {
340 t.Fatalf("rotation %d: expected error", i+1)
341 }
342 }
343
344 expectedCalls := int64(maxMultiFetcherFullLoopFailures)
345 if ca, cb := a.calls.Load(), b.calls.Load(); ca != expectedCalls || cb != expectedCalls {
346 t.Fatalf("after %d rotations: expected each fetcher called %d times, got a=%d b=%d",
347 maxMultiFetcherFullLoopFailures, expectedCalls, ca, cb)
348 }
349
350 // Subsequent calls must hard-error with ErrMultiFetcherExhausted and
351 // must not invoke the inner fetchers again.
352 _, err := mf.Fetch(ctx, "/x")
353 if !errors.Is(err, ErrMultiFetcherExhausted) {
354 t.Fatalf("expected ErrMultiFetcherExhausted, got %v", err)
355 }
356 if ca, cb := a.calls.Load(), b.calls.Load(); ca != expectedCalls || cb != expectedCalls {
357 t.Fatalf("inner fetchers called after exhaustion: a=%d b=%d", ca, cb)
358 }
359 }
360
361 // TestMultiFetcherConcurrent exercises the locking paths under -race by
362 // hammering one MultiFetcher from many goroutines, mirroring how
363 // fetchMigrations spawns parallel downloads against a shared fetcher.
364 func TestMultiFetcherConcurrent(t *testing.T) {
365 ctx := t.Context()
366
367 bad := &countingFetcher{}
368 good := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
369 mf := NewMultiFetcher(bad, good)
370
371 const goroutines = 16
372 const callsPerGoroutine = 8
373
374 var wg sync.WaitGroup
375 wg.Add(goroutines)
376 for range goroutines {
377 go func() {
378 defer wg.Done()
379 for range callsPerGoroutine {
380 if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
381 t.Errorf("unexpected fetch error: %v", err)
382 return
383 }
384 }
385 }()
386 }
387 wg.Wait()
388 }
389
390 // TestMultiFetcherSuccessResetsCounter verifies that any successful Fetch
391 // resets the loop-failure counter, so transient blips during a long session
392 // don't accumulate toward the exhaustion cap.
393 func TestMultiFetcherSuccessResetsCounter(t *testing.T) {
394 ctx := t.Context()
395
396 bad := &countingFetcher{}
397 good := NewHttpFetcher(testIpfsDist, testServer.URL, "", 0)
398 mf := NewMultiFetcher(bad, good)
399
400 // Many alternating success calls must not trip the breaker.
401 for i := range maxMultiFetcherFullLoopFailures * 3 {
402 if _, err := mf.Fetch(ctx, "/kubo/versions"); err != nil {
403 t.Fatalf("call %d: %v", i+1, err)
404 }
405 }
406 if err := mf.exhaustedErr(); err != nil {
407 t.Fatalf("breaker tripped despite repeated successes: %v", err)
408 }
409 }
410
411 // TestMultiFetcherContextCancelled verifies that a cancelled context exits
412 // the rotation early without quarantining every fetcher or counting toward
413 // the exhaustion cap. Otherwise three user Ctrl-Cs in a row would latch
414 // ErrMultiFetcherExhausted on a perfectly healthy gateway list.
415 func TestMultiFetcherContextCancelled(t *testing.T) {
416 a := &countingFetcher{}
417 b := &countingFetcher{}
418 mf := NewMultiFetcher(a, b)
419
420 ctx, cancel := context.WithCancel(t.Context())
421 cancel()
422
423 for i := range maxMultiFetcherFullLoopFailures + 2 {
424 _, err := mf.Fetch(ctx, "/x")
425 if !errors.Is(err, context.Canceled) {
426 t.Fatalf("call %d: expected context.Canceled, got %v", i+1, err)
427 }
428 }
429
430 // Each call should have exited after the first fetcher returned the
431 // cancellation error, so b is never tried and the breaker never latches.
432 if got := b.calls.Load(); got != 0 {
433 t.Fatalf("second fetcher should not be tried after cancellation, got %d calls", got)
434 }
435 if err := mf.exhaustedErr(); err != nil {
436 t.Fatalf("breaker latched on cancelled-context loops: %v", err)
437 }
438 }
439
440 // countingFetcher always errors and records how many times it was called.
441 // The counter is atomic so the fetcher is safe to share across goroutines
442 // during -race tests.
443 type countingFetcher struct {
444 calls atomic.Int64
445 }
446
447 func (c *countingFetcher) Fetch(ctx context.Context, _ string) ([]byte, error) {
448 c.calls.Add(1)
449 return nil, fmt.Errorf("countingFetcher always fails")
450 }
451
452 func (c *countingFetcher) Close() error { return nil }