| index 24bb9174a..5ed8ca61c 100644 |
| --- a/internal/hostfiles/run_test.go |
| +++ b/internal/hostfiles/run_test.go |
| @@ -5,8 +5,10 @@ package hostfiles |
| import ( |
| "context" |
| "errors" |
| + "log" |
| "os" |
| "path/filepath" |
| + "strings" |
| "sync" |
| "testing" |
| "time" |
| @@ -255,3 +257,121 @@ func TestRunIgnoresAnEventForAPathThatEscapes(t *testing.T) { |
| t.Fatalf("run ended with %v; a bad path must be skipped, not fatal", err) |
| } |
| } |
| + |
| +// logCapture is a log.SetOutput target that is safe to write from the loop. |
| +type logCapture struct { |
| + mu sync.Mutex |
| + b strings.Builder |
| +} |
| + |
| +func (l *logCapture) Write(p []byte) (int, error) { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return l.b.Write(p) |
| +} |
| +func (l *logCapture) count(sub string) int { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + return strings.Count(l.b.String(), sub) |
| +} |
| + |
| +func captureLog(t *testing.T) *logCapture { |
| + t.Helper() |
| + c := &logCapture{} |
| + log.SetOutput(c) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + return c |
| +} |
| + |
| +// A repair is a tree walk: a cause that lasts must cost one attempt and |
| +// one log line per polling period at most, not one per tick. |
| +func TestRunRepairsAtMostOncePerPollAndLogsTheTransitionOnce(t *testing.T) { |
| + logs := captureLog(t) |
| + s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake() |
| + w.setDegraded(true) |
| + w.repairErr = errors.New("watch limit reached") |
| + p := open(t, s) |
| + stop := startRun(t, p, dir, w, polled) |
| + time.Sleep(10 * polled.Poll) |
| + stop() |
| + w.mu.Lock() |
| + n := w.repairs |
| + w.mu.Unlock() |
| + if n < 3 || n > 13 { // ten periods; a tick is a quarter of the deadline, so per-tick would be ~40 |
| + t.Fatalf("%d repair attempts in ten polling periods; want about ten", n) |
| + } |
| + if got := logs.count("watch coverage is missing"); got != 1 { |
| + t.Fatalf("coverage-missing logged %d times, want once", got) |
| + } |
| + if got := logs.count("still missing"); got != 0 { |
| + t.Fatalf("a per-attempt line was logged %d times", got) |
| + } |
| +} |
| + |
| +func TestRunLogsWhenCoverageIsRestored(t *testing.T) { |
| + logs := captureLog(t) |
| + s, dir, w := release.NewDirStore(t.TempDir()), t.TempDir(), newFake() |
| + w.setDegraded(true) |
| + p := open(t, s) // Repair succeeds on its first attempt |
| + stop := startRun(t, p, dir, w, polled) |
| + eventually(t, "restored line", func() bool { return logs.count("watch coverage restored") == 1 }) |
| + stop() |
| + if got := logs.count("watch coverage is missing"); got != 1 { |
| + t.Fatalf("missing logged %d times, want once", got) |
| + } |
| +} |
| + |
| +// failingPuts refuses every object upload, so no scan of a directory with a |
| +// file in it can finish. |
| +type failingPuts struct { |
| + release.Store |
| + mu sync.Mutex |
| + puts int |
| +} |
| + |
| +func (f *failingPuts) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + if strings.HasSuffix(key, "manifest.json") { |
| + return f.Store.Put(ctx, key, body, etag) |
| + } |
| + f.mu.Lock() |
| + f.puts++ |
| + f.mu.Unlock() |
| + return "", errors.New("store unavailable") |
| +} |
| + |
| +func TestRunRetriesAFailingScanOncePerPollAndLogsItOnce(t *testing.T) { |
| + logs := captureLog(t) |
| + fs := &failingPuts{Store: release.NewDirStore(t.TempDir())} |
| + dir := t.TempDir() |
| + write(t, dir, "f", "v") |
| + p := open(t, fs) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan error, 1) |
| + go func() { done <- run(ctx, p, dir, polled, newFake()) }() |
| + time.Sleep(10 * polled.Poll) |
| + cancel() |
| + <-done |
| + fs.mu.Lock() |
| + n := fs.puts |
| + fs.mu.Unlock() |
| + if n < 3 || n > 13 { |
| + t.Fatalf("%d scan attempts in ten polling periods; want about ten", n) |
| + } |
| + if got := logs.count("scan failing"); got != 1 { |
| + t.Fatalf("the failure was logged %d times, want once", got) |
| + } |
| +} |
| + |
| +func TestRunWithZeroOptionsUsesTheDefaultsAndPublishesOnItsFirstScan(t *testing.T) { |
| + s, dir := release.NewDirStore(t.TempDir()), t.TempDir() |
| + write(t, dir, "a", "a") |
| + p := open(t, s) |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + done := make(chan error, 1) |
| + go func() { done <- run(ctx, p, dir, Options{}, nil) }() |
| + eventually(t, "a published", func() bool { return manifestHas(s, "a", sum([]byte("a"))) }) |
| + cancel() |
| + if err := <-done; !errors.Is(err, context.Canceled) { |
| + t.Fatal(err) |
| + } |
| +} |